Commit Graph
62 Commits
Author SHA1 Message Date
LessaandBrandon Thomas 53d09f5850 Preserve HDR range in float adjustments and stack statistics (#2592)
* Preserve HDR range in float adjustments and stack statistics

* Cover HDR exposure round trips and Levels record order

* Report the backend used by GPU parity tests

* Compare float PSD oracles at native depth and preserve signed Exposure

* Retain merged alpha metadata when decoding float references

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 10:44: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
Valentin KleinandClaude Opus 5.5 e7c5df0d69 perf: keep resident GPU tiles when a brush dab grows a layer (#2900) (#2902)
A dab that painted past a layer's stored tiles changed its resident page
region, which recreated the texture and converted and uploaded every tile
of the layer in that page cell again. Copy the overlap from the old
texture on the GPU instead (in its own submission, ahead of the queued
tile writes) and keep its tile map, so only new or changed tiles upload.

32-bit float RGB/gray tiles also convert to RGBA16F straight from their
samples (same bytes as the general path) instead of decoding into a float
buffer and converting pixel by pixel.

P16 (32-bit, 20 layers) brush dab + refresh, median of 6 alternating runs:
p50 8.9 -> 3.1 ms, p95 27.2 -> 7.5 ms. P13 is unchanged (~1.6 / ~4.5 ms).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:01:03 -04:00
1f64daeba4 Speed up large feathered mask refreshes (#2417)
* Speed up large feathered mask refreshes

* Format preference label for workspace check

* gpu test: allow float noise on a one-code vector-mask parity step

The combined mask is cached as U16 now; one pixel lands exactly one 8-bit
code apart (1.0000075/255), within the <=1/255 parity rule.

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 07:09:04 -04:00
a8ad82dd46 GPU composite: reuse unchanged lower-stack chunks for property edits (#1169)
* perf(gpu): reuse unchanged lower-stack composite chunks

* test(gpu): verify half-float composite checkpoint reuse

* docs: record measured GPU composite reuse gains and limits

* perf(gpu): admit whole grid chunks, serve partial damage as sub-rect copies

Cache entries were keyed by the exact damage rectangle, so brush strokes
(a new partial rect every frame) filled the budget with entries that
never hit again. Entries are now whole chunk-aligned, chunk-sized grid
cells: a capture merges into the cell it overlaps at the rectangle's
offset, a read is served as a sub-rect copy of the covered part, and
coverage is tracked per cell so a resume only happens where every pixel
of the requested rectangle is cached. After a full render, any in-bounds
partial rectangle hits.

Regression test: two series of disjoint strokes inside the same chunks
leave composite_cache_bytes unchanged (no entry-per-rectangle growth),
a full refresh still finds its chunks and a top-layer edit afterwards
reuses them. The old partial-damage test now expects the covered-chunk
hit and checks outside-the-document renders admit nothing.

* docs: record the GPU reuse measurement in target-app-parity.md

The honest assessment moved from roadmap.md to target-app-parity.md on
main; keep main's roadmap and add only the dated measurement.

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 02:07:48 -04:00
96ec16ed7d fix(compose, gpu): round dithered gradient fills to the document depth (#2755) (#2781)
A live gradient's dither adds a fraction of a level of noise, but gradient
fill layers kept it in float. Color Dodge divides by 1 - cs, so under a
bright dodge layer that sub-level noise came out as speckle several levels
high, and channels at 0 were lifted off black (coloured fringes).

Dithered gradient pixels are now rounded to the document depth's levels
(8/16-bit; float documents unchanged), as a painted gradient stores them.
Same rounding in the GPU shader. Regression tests in compose and a GPU
parity case for stacked dithered gradients under Color Dodge.


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

Co-authored-by: Leel Wasanatha <260480244+kavishkashehan726-sudo@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:11:46 -04:00
d166450c77 Hue/Saturation: Colorize at 0% saturation gives grey, not a 25% tint (#2343)
Colorize floored saturation at 25% on both the CPU (compose::adjust::hue_saturation) and the
GPU shader, so dragging Saturation to 0 with Colorize ticked still tinted the image. Photoshop
leaves only the lightness at 0%. The floor is gone from both paths, so 1-24% now tint
progressively too.

The floor also stood in for Photoshop's 25% starting saturation when Colorize is switched on.
The dialog's checkbox already sets that; the hueSaturation params now do the same when a call
turns Colorize on without giving a saturation, so {"colorize": true, "hue": 30} still tints.

Fixes #2326.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:16:26 -04:00
MrCloudy2andClaude Opus 5.5 4760f75b4d Blend modes: Fill below 100% works inside the blend for Photoshop's special eight (#2552)
* Blend modes: Fill below 100% works inside the blend for Photoshop's special eight

In Photoshop, Fill and Opacity are the same for most blend modes, but not
for eight: Color Burn, Linear Burn, Color Dodge, Linear Dodge (Add),
Vivid Light, Linear Light, Hard Mix and Difference. PhotoCraft treated
Fill as Opacity for all of them, so a Difference layer at 50% Fill gave
(150,90,110) where Photoshop gives (150,60,10).

Measured on Photoshop 27.11 (scripted, 24 cases at Fill 25/50/75% with
two colour pairs): below 100% Fill, Photoshop pulls the layer's colour
toward the mode's neutral colour (black for Color Dodge, Linear Dodge,
Difference; white for Color Burn, Linear Burn; 50% gray for Vivid and
Linear Light) and blends that at full strength; Hard Mix becomes
clamp((b - f(1 - t)) / (1 - f)). Opacity still mixes the result in.
That reproduces 31 of 32 values within 1 level (Hard Mix at 75%: 3).

CPU (psblend::composite_fill, the final blend of plain pixel, shape and
type layers) and GPU (Kernel::Blend takes Fill in the spare uniform slot)
agree; Dissolve keeps Fill as coverage. Layers with effects, adjustment
layers and groups keep the old behaviour for now.

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

* GPU: composite_fill with a single blend_rgb call, so fs_blend stays small

The first version added a third inlined copy of blend_rgb (every blend
mode) to fs_blend: composite_g's two plus blend_rgb_fill's. Windows CI
(software WARP adapter) then timed out rendering the UI in
arbitrary_rotation_preview, which passes on main; a larger shader
compiles noticeably slower there.

composite_fill now handles every case itself with one blend_rgb call
(Fill as coverage for normal modes, the special-eight blend, Lab Normal
mixing, the text gamma space) and is fs_blend's only composite path,
leaving it with fewer inlined copies than on main. Same results: the 35
GPU parity tests pass.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:16:20 -04:00
43a2cfdf6d Bevel contour: drop the glow-style Range stretch (#2498)
The bevel Contour element mapped the height through contour(min(v / range, 1)),
the glow transfer. At the default 50 % range that flattened a Linear contour to
a bevel half as wide as Photoshop's: on a real 18 px smooth inner bevel at
136 % depth the top-edge highlight was gone 15 px in, where Photoshop's is
still 0.19 at 16 px. The contour alone matches that file (bad pixels 0.38 % ->
0.03 %, max error 63 -> 25 / 255) and lowers the bad pixels of every contour
oracle (bevel-inner-contour-ramp-peak 25.9 -> 21.8 %, -double-ring-aa-r70
25.2 -> 21.0 %, bevel-pillow-chisel-hard-gloss-and-contour 11.4 -> 7.2 %);
the other 23 bevel oracles are unchanged. What Range does instead is open.

CPU (compose) and GPU (fx) share the new bevel_contour_lut.

Co-authored-by: Justin Campos <justin@justincampos.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:16:14 -04:00
MrCloudy2andClaude Opus 5.5 2c163bf73d Legacy Brightness/Contrast: brightness first, then contrast around 128, as in Photoshop (#2615)
* Legacy Brightness/Contrast: brightness first, then contrast around 128, as in Photoshop

With Use Legacy on, PhotoCraft applied contrast first and then added
the brightness, so any setting that moves both sliders came out wrong:
brightness +40 with contrast +30 mapped mid-grey 128 to 168, where
Photoshop gives 185 (up to 17 levels darker across the midtones).

Measured on Photoshop 27.11 (scripted, a gray ramp through seven
settings): Photoshop adds the brightness, then scales contrast around
128/255. Contrast 80 keeps 128 at 128; the 0.5 pivot moved it to 130.
With both changes every measured value matches within 1 level, on the
CPU and in the GPU adjustment kernel.

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

* Legacy B/C: brightness after contrast when contrast is lowered

The corpus caught it: psd-tools' brightnesscontrast_legacy_grayscale.psd
(brightness -23, contrast -26) matched Photoshop with the old order and
was 6 levels off with brightness first. Photoshop adds the brightness
before contrast when contrast is raised (measured: +40/+30 maps 128 to
185) and after it when contrast is lowered. The measured cases with
lowered contrast had brightness 0, so they could not tell.

CPU and GPU kernel (brightness after contrast goes in p0.w). The io
corpus oracle is back at its floor; the 34 GPU parity tests pass.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:15:36 -04:00
ericandhideoneric 5b051861bc fix(blend): keep 32-bit Hue/Saturation/Color/Luminosity above 1 (#1735)
- Hue, Saturation, Color and Luminosity no longer clip 32-bit documents to 0..1, so values above 1 survive in the CPU compositor (blend_rgb_hdr now uses blend_rgb_within at f32::MAX) and in the GPU shader (under the existing F_HDR flag). 8/16-bit results are unchanged apart from rounding at white.
- ClipColor returns the grey when the luminosity is outside the in-gamut range, instead of dividing by max − lum rounding noise. The old formula turned colours inside out (Luminosity of a grey of 2 over (0.5, 0.25, 0.125) gave (1.0, 2.32, 2.99)), and CPU and GPU disagreed (Hue over a grey of 2: CPU 1.0, GPU 2.82).
- Tests: 8/16-bit inputs match the ISO 32000 formula, HDR cases in compose, psblend::with_hdr nesting, and a CPU/GPU parity test across depths.

Co-authored-by: hideoneric <hideoneric@users.noreply.github.com>
2026-10-10 13:15:18 -04:00
Rene Leonhardt 5290930e4a fix: Preserve CMYK solid and gradient fills through PSD round trips (#2628)
* fix(compose): Preserve native CMYK fills through PSD round trips

Share depth-quantized ink samples between rendering and PSD caches.
Use the document profile for RGB fill colours and preserve valid imported
caches. Uncached CMYK fills use CPU rendering.

Cover all sample depths, gradient styles and tiled renders. Fix the real
16-bit gradient round trip and raise the psd-tools floor from 307 to 308.

* perf(raster): Reuse exact 8-bit CMYK conversions within rows

Cache the previous ink bytes and ICC result per row, with alpha decoded
independently. Keep the 16/32-bit conversion loops unchanged.

Matched 1 MP reads improve by 77% for smooth ramps, 33% with dithering
and 10% for random colours. Bitwise regression coverage includes all
depths, alpha and tile boundaries.
2026-10-10 13:11:54 -04:00
MarekSpodBiedry 8c2e16fb4a GPU canvas: re-upload a layer or mask texture when its default pixel changes (#2422) (#2446) 2026-10-10 13:08:14 -04:00
Sir StarchandBrandon Thomas a737281fad Distance fields on threads: bevel size changes 2-3x cheaper (#2119) (#2162)
* Distance fields: the Euclidean passes run on threads, results unchanged (#2119)

The chiselled bevel fields (#2006) and the precise glow fields are built
from an exact Euclidean distance transform, a few sweeps over the whole
map that all ran on one thread. They now run on scoped OS threads, in
groups of rows or columns handed out from a queue: edt_nearest (the
column pass, then the row pass), sharp_edges (rows), refine_edge_normals
(one edge pixel each) and the final per-pixel maps of the bevel and plain
Euclidean fields. Not rayon, as for the blurs (rows_par): maps are built
from inside rayon tiles, where a waiting worker steals other tiles (#276).
Maps under 65536 pixels stay on the calling thread, wasm always does.

Every unit is computed from its own inputs, in the same arithmetic as
before, so the fields are bit for bit the old ones whatever the thread
count: 216 fingerprints (bevel in and out, plain in and out, bevel_maps on
bars at 0 to 150 degrees, thin bars, discs, rounded rectangles, soft,
patchy and half-opaque masks, 40 stars, tiny and 1 pixel maps) are equal
at 1, 7, 16 and the default threads, and equal to the code before.
edt_nearest returns the nearest pixel as u32 (it was usize), which takes its
working memory from 20 to 16 bytes per pixel; a map past 2^32 pixels or a slice that does
not match its size gets "far" distances instead of a panic.

CPU compositor, 12 MP, bevel size change (ms, bevel_bench), main -> this:
ellipse 474 -> 217 (size 8), 990 -> 318 (250); text 747 -> 214 (8),
1110 -> 267 (250). A distance_field call on a 256x256 map goes from 2.2 to
1.8 ms, on 512x512 from 8.0 to 2.8, on 2048x2048 from 99 to 15.

Tests: par_units hands every unit to the closure once with its own
slices (short last unit, no second slice, units of different sizes, 1 to
64 threads); the nearest-pixel transform equals itself on 1, 2, 3 and 8
threads on awkward sizes and returns a seed at the distance it reports;
degenerate sizes; sharp edges and the four Euclidean fields are bit-equal
on 1, 2, 5 and 16 threads on discs, rounded rectangles, bars at several
angles, patchy opacity and an edge on the border. The nested-rayon
deadlock test (#276) now has a chiselled bevel layer in its document.

* GPU effect maps: the Euclidean fields are one window, not bands (#2119)

A bevel size change rebuilds the distance fields over the whole effect
region (the region grows with the size). The GPU path cut that into bands
of max(128, halo) rows, each computed from a window grown by the halo,
which for the bevel fields is reach + 10: at size 250, 262-row bands from
windows of about 786 rows. Every pixel went through the transform and the
edge refinement about three times, a 12 MP region gave about 12 tasks for
32 threads, and the two bevel fields ran one after the other.

The Euclidean fields (chiselled bevels, precise glows) now come from one
window, and distance_field spreads its own passes over the threads. A
rebuild is the whole region, which is also what the CPU compositor
computes, so the GPU canvas gets exactly the CPU's field: bands resolved
ties between equidistant edge pixels per window, and on a rebuild they
differed from the whole region by up to 0.07 px (ellipse) and 1.3 px (600
discs) at size 250, on a few dozen pixels in 12 MP. A partial update (a
brush dab) still computes the damage grown by the halo, from one window.
The chamfer fields (shadows, strokes, spread) are a sequential sweep and
keep the bands, and so does wasm, where there are no threads to spread
over and bands keep the memory small.

RTX 4080 SUPER, release, 12 MP, ms per size change on the GPU canvas
(bevel_bench from #2118, p50, median of 3 runs, before #2006 / main /
this), ellipse: 90 / 103 / 98 (8), 162 / 188 / 119 (50), 286 / 446 / 219
(250); text: 83 / 139 / 113, 140 / 215 / 130, 222 / 489 / 165. Alone in the
process (--no-cpu) ellipse at 250 is 407 -> 157 and text 468 -> 169.
36 MP ellipse at 250: 779 -> 382. Peak RSS: 12 MP at 250 1604 -> 862 MiB,
36 MP at 250 2987 -> 1726 MiB; at size 8 on 36 MP it is 163 MiB more
(1380 -> 1543). Precise glow fields on 12 MP: 46-84 ms -> 26 ms at reach
52-252 (32 ms at 12).

The window test now checks that the whole region is bit for bit the CPU's
field, and that a too small halo is noticed on a part of the region.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:38:33 -04:00
SilentYetiandClaude Opus 5.5 db0fb07862 Add native layered Paint.NET (.pdn) import (#1998)
Squashed onto main, with the maintainer's asks applied:
- Photoshop's blend menus (layers, brush, pencil, gradient, layer style) stay as they are;
  the Layers panel and Blending Options list a Paint.NET mode only for a layer that uses one.
- The six Paint.NET mode labels are translated in every catalog, and the blend-mode i18n test
  iterates layer_modes().
- pdn::pixels rejects truncated uncompressed (version 1) data before allocating its buffer.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:05:00 -04:00
72d78f3067 Patterns paint in the document's colour mode (Gray, CMYK) (#1950)
An RGB pattern (every built-in one) showed in colour as a pattern fill or
Pattern Overlay in a Grayscale or CMYK document, unlike Edit > Fill, the
export, and Photoshop, which fills in grey but keeps the pattern's colours
(a Grayscale round trip brings them back).

pattern::pattern_rgba projects a pattern's pixels through the document's
mode when painted; the CPU prepared tiles and the GPU pattern textures use
it. Patterns themselves are unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 19:12:45 -04:00
Sir Starch f4c1048ead Chisel bevel: facets are flat at any edge angle (#1926) (#2006)
* Layer styles: chiselled bevel facets are flat at any edge angle (#1926)

Chisel Hard and Soft shaded the facets of an edge at 30, 60, 120 or 150
degrees in regular cross-stripes (a comb along the edge), while 0, 45, 90
and 135 degree edges were clean. The height of a chisel is a linear ramp of
the distance to the edge, and that distance was measured to the nearest
pixel centre of a thresholded mask: an oblique edge is a pixel staircase,
so the distance ripples with its period (0.19 px at 60 degrees), and the
shading is a gradient of it, scaled by depth.

Chisel bevels now measure to the edge itself. A sharp anti-aliased edge
pixel (partly covered, touching an empty and a full pixel) places the edge
by its coverage along its own normal (edge_offset inverts the area a
straight edge cuts off a pixel), and a pixel measures to the line of the
edge pixels around the foot of its perpendicular, not to the centre of the
nearest one (edge_distance). The ripple drops to 0.01 px. Hard-edged masks
and soft ramps have no sharp edge pixel and give exactly the plain fields.

The GPU compositor takes the bevel distance fields from the CPU through
new FieldKind::BevelInside / BevelOutside (the glow fields are unchanged);
distance_field takes the reach so values past it are not refined, and the
band halo of the bevel fields grows to reach + 9.

* Chisel bevel: edge normals from a line fit, exact at hard stretches (#1926)

A follow-up to the previous commit, from measuring how far it was from the
best it can do. The 3x3 coverage gradient gives an edge's direction to 1-3
degrees, and the error is systematic, so every distance tilted the same way
and left a 0.010-0.017 px ripple. With the true normals the ripple is 0.002
to 0.004 px, so the normal was the limit.

refine_edge_normals fits a line through the edge points (pixel centre moved
to its edge) of the sharp pixels within 3 px that agree to 25 degrees, and
takes the line's normal where the points really are on a line (spread along
it at least 0.7 px, off it at most 0.1 px). Corners and tight curves keep the
gradient's normal. On straight edges it reaches the ideal-normal floor.

The inside field now seeds from the empty pixels, as the plain field does,
and measures to the sharp pixel next to the nearest empty one, capped by the
plain distance (which never falls short of the true one). A circle's pole,
or a rounded rectangle on whole pixels, has a stretch of edge exactly on a
pixel boundary with no sharp pixel; the distance to the sharp pixels alone
was up to 0.29 px too long there. A mask without sharp pixels still gives
exactly the plain field.

Measured on anti-aliased shapes: ripple on straight edges 0.0105 -> 0.0038
(0.0172 -> 0.0021 at 75 degrees); a disc of radius 70, inside, worst error
0.29 -> 0.02. The band halo of the bevel fields is reach + 9 and the banded
test now sees a halo that is too small (the old one passed at reach + 3).

* Chisel bevel: sparse sharp-edge storage, no full-map copies (#1926)

Measured before sending the fix: the new
bevel fields made a bevel size change in the GPU canvas about twice as slow
(12 MP: 84 -> 161 ms per step), and nearly all of a step is the CPU effect
maps. The cost of the normal refinement was memory, not arithmetic: a
Vec<Option<SharpEdge>> is 16 bytes for every pixel, refine_edge_normals
copied the whole map twice for a few thousand edge pixels, and
local_coverage cloned the whole map and read nine pixels for each.

SharpEdges keeps a 4 byte slot per pixel and a short list of the edge
pixels. The candidates are found by a row by row pass over which pixels are
covered (an edge pixel is covered and has an empty neighbour), and the
coverage is worked out where it is needed by the same formula as
local_coverage. The refinement works on the list. Nothing else changes:
the fields are bit-identical (FNV fingerprints of 27 maps at 5 reaches and
of whole bevel_maps at 3 sizes, 216 of 216 equal), and the rendered PNGs of
the issue scene and a realistic scene are byte-identical.

Release, 12 MP ellipse, size 8, one thread: new fields 408 -> 351 ms (old
fields 290); the sharp pass 48 -> 19 ms and the PCA step 32 -> 1 ms.
GPU canvas, ms per size change, main / before / after: 12 MP ellipse
84 / 161 / 96, 300 stars 122 / 246 / 166, text 91 / 184 / 139, 36 MP
ellipse 295 / 522 / 336. What is left on edge-heavy content is the
neighbour search and edge_distance, which this commit does not touch.
2026-10-09 14:22:21 -07:00
RaghavandClaude Opus 5.5 45b22e7403 GPU: gradients with coincident stops fall back to the CPU compositor (#2005)
The 4096-entry ramp LUT interpolated across a hard stop, showing grey where the CPU export is
white. Gradient fills, Gradient Maps and gradient effects with coincident colour or opacity stops
now return Unsupported so the canvas uses the CPU oracle. Fixes #974.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:30:42 -04:00
shmuelbiller1andClaude Opus 5.5 f1413ea12e Outer Glow stays behind a see-through fill (#1964)
Outer Glow now gets the same fill-dependent knockout as Drop Shadow, on CPU and GPU, so a Fill 0%
shape with a glow no longer paints the glow across its interior. Fixes #1953.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:26:38 -04:00
khhjoeandClaude Opus 5.5 01bdf39f1b gpu: write F32_MAX exactly so the compositor shader builds on WebGPU (#1982)
The decimal 3.40282347e38 is just above f32::MAX; naga rounds it but browsers' Tint rejects it, so
the web app had no GPU compositor. Now a hex literal, with a test that every decimal float literal in
the shader fits f32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:22:30 -04:00
David MannandClaude Opus 5.5 45c5001dfd GPU canvas: convert changed layer tiles once each, in parallel (#1847)
Changed tiles are grouped by shared Arc and converted once each in parallel batches; a tile
shared by several coordinates (a solid fill, a cleared area, a non-zero default) is uploaded
once to a scratch tile and copied on the GPU, and new pages take the same path. 16-bit RGB and
gray tiles convert to RGBA16F through a lookup table. Select All + Fill on a 50 MP 16-bit page
refreshes in 15-19 ms instead of about 1.4 s. Fixes #1774.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:04:21 -04:00
Sir StarchandClaude Opus 5.5 9bd935498b GPU parity tests: give the one-step tolerance f32 slack (#1852)
Two results exactly one 8-bit step apart can land a few f32 ulps past 1/255 after premultiplying
and subtracting (NVIDIA, Hue/Saturation in adjustment_layers); allow 4 epsilon of slack. Fixes #1848.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:51:36 -04:00
ericandhideoneric 60018dea69 fix(compose): don't clip Add and Divide at 1 in 32-bit documents (#1722)
- Linear Dodge (Add) and Divide keep values above 1 in 32-bit float documents, clipped only at f32::MAX. Integer depths still clip at 1.
- CPU: add psblend::HDR and generic::blend_rgb_hdr / blend_channel_hdr; render sets the flag per document depth.
- GPU: add the F_HDR pass flag, and compose.wgsl uses it for the same clip bound.
- Non-separable modes are unchanged.
- Tests: colour unit tests, compose depth tests, and the GPU parity test float_documents_blend_past_white.

Co-authored-by: hideoneric <hideoneric@users.noreply.github.com>
2026-10-09 18:00:39 +07:00
MarekSpodBiedry 66ab5012be fix(compose): a Precise inner glow from the centre is no longer blank (#966) (#1708) 2026-10-09 18:00:26 +07:00
Harrison O'Connor-Hoover 5355221f81 Preserve document depth when splitting vector shapes (#1495) 2026-10-09 09:01:39 +07:00
b0680fd421 Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (FILE-215-16, part of #215) (#903)
* Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (part of #215)

Blending Options > Advanced Blending now works end to end:

- doc: AdvancedBlending on every layer (knockout none/shallow/deep, blend
  interior effects as group, blend clipped layers as group, transparency
  shapes layer, layer/vector mask hides effects), saved in .pcraft.
- PSD and layered TIFF: knko, infx, clbl, tsly, lmgm, vmgm read into the
  model and written back (in place, or added when not at the defaults);
  the TIFF byte-order transcoder handles lmgm/vmgm as byte flags.
- CPU compositor (compose/src/advanced.rs): shallow and deep knockouts
  (isolated groups and clipping groups stop a deep knockout), clipped
  layers blended individually, effects over the whole layer when
  Transparency Shapes is off, effects built from an unmasked copy when a
  mask hides them, interior effects under fill opacity.
- GPU: documents using Advanced Blending fall back to the CPU compositor.
- engine: layer.layerStyle.blendingOptions takes channels, knockout and the
  five switches; inspect reports them; Copy/Paste Layer Style carries them.
- UI: the Blending Options page laid out like Photoshop's (General,
  Advanced Blending, Blend If with split sliders), translated.

psd-tools corpus oracle 229 -> 236 (seven knockout files now pass; floor
raised); other corpora and all round trips unchanged. Scorecard
FILE-215-16 -> partial.

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

* Polish catalog rows for Advanced Blending; regenerate docs/scorecard.md

pl.tsv landed on main and the coverage test needs every catalog to translate new UI strings.

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

* Undo damage: compare Layer.advanced in layer_pixels_equal; regenerate scorecard

#810's exhaustive Layer destructure must classify the new advanced-blending
field; it changes the composite, so it is compared.

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

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 20:08:03 -04:00
UnemarsandClaude Opus 5.5 4649946900 Vibrance: Photoshop's Saturation and Vibrance, measured (linear light, 0.288 R + 0.712 G grey) (#1418)
Saturation −100 greyed to the HSL lightness, so a yellow (255, 255, 0) became grey 128 where
Photoshop shows near-white: a yellow logo with Brightness/Contrast +150 and Vibrance › Saturation
−100 clipped to it stayed yellow under Lighter Color. Measured black-box on Photoshop 25.4
(16-bit runs of 7,400-colour charts and HSV sweeps; sRGB, Adobe RGB and ProPhoto documents), both
sliders work in linear light through the document tone curve, Vibrance first, then Saturation:

- Saturation moves each colour from the grey 0.288 R + 0.712 G (blue has no weight, in every
  profile) by 1 + s, clipping per channel: within 0.07/255.
- Negative Vibrance is an HSV change in linear light keeping the hue, in closed form: 0.2/255 on
  average, the deepest shadows (V < 0.05) drift.
- Positive Vibrance: the brightness term is exact; the saturation boost (stronger for dull and
  dark colours, damped on reds and skin) is a fit, 0.4 (+10) to 5 (+100) /255 on average.

CPU (compose::adjust::vibrance_px) and GPU (compose.wgsl) share the constants; the parity test
gains a negative Vibrance and Saturation −100. photocraft-corpus adjustments/rgb8|rgb16/
vibrance.psd (Vibrance +60, Saturation −15): 25.6/255 mean, 89 max → 0.38 mean, 5 max.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:28:42 -04:00
llduyll10andClaude Opus 5.5 f985b81300 Layer style: an Outside stroke at Fill below 100 % leaves the interior clear (#906)
Fill 0 % + an Outside stroke is Photoshop's "outline only" look, but on pixel
layers the stroke lay beneath the whole interior at full coverage, relying on
the layer composited on top to hide it. With the fill lowered the interior
showed the stroke colour (a solid disc instead of a ring).

The share beneath an interior pixel is now (1 - a) / (1 - c), with a the
shape's coverage and c the layer's alpha after fill: 1 under an opaque
layer (100 % fill renders exactly as before, so PSD oracles don't move),
1 - a at 0 % fill. CPU compositor and the wgpu shader agree (the layer's
alpha now rides along the coverage seed for pixel layers too).

Tests: compose regression at 8/16/32-bit (0 %, 50 %, 100 % fill), and a GPU
parity case at fill 0 and 0.5 (fails without the shader change).

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:09:16 -04:00
Tanish Manku 5049a1d062 Never crash on hostile numbers: LUT sizes, Quick Selection points, link group ids (#869)
- Color Lookup: a stored document's `size` is untrusted. `size³ × 3` overflowed for sizes of
  2²² and up: debug builds panicked, release builds wrapped past the table-length guard and
  indexed out of bounds. One checked `photocraft_doc::adjust::lut3d_len` now backs the CPU
  compositor, the GPU planner and the PSD writer.
- Quick Selection: a stroke point far off the canvas (`select.quick {"points":[[3e9,0]]}`)
  saturated to i32::MAX and then overflowed on `+ 1`.
- Link Layers: `max + 1` over stored link group ids overflowed at u64::MAX (debug panic, release
  wrap that could merge unrelated layers). A fresh id is now the smallest unused one when the
  largest is taken; a selection's first group is read with `first()`.
- Vanishing Point clone footprint: the same `as i32 + 1` saturates (hardening; no reproducer).
2026-10-08 15:04:34 -04:00
452672765d Photo Filter and gradient glows render like Photoshop (#442, #443) (#850)
* Photo Filter and gradient glows render like Photoshop (#442, #443)

Photo Filter multiplied encoded RGB by the filter colour and rescaled to Rec.601 luma. Photoshop
multiplies the pixel's D50 XYZ (relative to white) by the filter colour's, mixed by density, and
Preserve Luminosity restores the encoded luminosity like the Luminosity blend mode (SetLum +
ClipColor); 32-bit documents keep luminance Y. rgb16 photo-filter.psd now matches within 0.6/255
(was 125/255); rgb8 mean error 0.3/255 (was 20.8).

Outer and Inner Glow gradients were laid out across the canvas (a vertical linear gradient), so
the colour bands ran top to bottom. Photoshop colours the glow at strength v with the gradient at
1 - v (first stop at the edge) and the glow is opaque from v = range^2. outer-glow-gradient.psd:
mean 1.4/255, max 9.9 (was 252).

CPU and GPU paths updated together; photoshop oracle floor 132 -> 133.

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

* Merge origin/main; regenerate docs/scorecard.md

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 23:49:10 -04:00
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
7313e08141 32-bit documents: Levels don't clip the input and output ranges (#403)
* 32-bit documents: Levels don't clip the input and output ranges

Photoshop's float Levels keep the curve going past the black and white points
and use a plain power for the gamma. rgb32 and gray32 levels.psd now match
within 0.5/255 (was 10/255).

* Raise the Photoshop oracle floor to 132

* Levels: destructive Image › Adjustments › Levels doesn't clip in 32-bit either; neutral comment wording

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 06:53:56 -04:00
floflo777 8a5e89faa6 32-bit documents: Exposure works on the stored linear values (#400)
* 32-bit documents: Exposure works on the stored linear values

Their samples are linear already, so going through the 2.2 curve applied
the gamma twice. rgb32 and gray32 exposure.psd now match Photoshop within
0.5/255 (was 69/255 and 44/255).

* Raise the Photoshop oracle floor to 130
2026-10-07 11:49:29 +07:00
Brandon Thomas 7c6a78b05a GPU: crash-safe startup and device-loss fallback (#243, #4) (#252) (#252) 2026-10-05 12:05:22 -04:00
Brandon Thomas dbb8911f2a Live gradients: Gradient tool makes editable Gradient Fill layers (on-canvas handles, stops, midpoints) + Classic mode (#180) (#197) (#197) 2026-10-05 12:04:52 -04:00
Brandon Thomas 8bac8926a3 PSD stroke effects: outlines of filled shapes, vector stroke above interior effects, joined subpaths (#233) (#233) 2026-10-05 11:48:28 -04:00
Brandon ThomasandClaude Opus 5.5 f114f62179 Layout documents: fix GPU device loss, live Move tool, local damage, no per-frame work (#125, #128) (#226) (#226)
Merged with #241 (menu_bar returns the menus' right edge) and #240 (stroke trail field).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:53:15 -04:00
Brandon Thomas 1d9d92341f PSD effects fidelity: spread metric, emboss, gradient geometry, text gamma (113 -> 118 oracle passes) (#224) (#224) 2026-10-05 10:52:47 -04:00
Goran MekićandClaude Opus 5.5 2ad42d028a Fix tests on FreeBSD with RADV (#194) (#194)
Rebased onto the paged GPU compositor (#187): the first-frame empty submit moves into encode_inner, and the test GPU lock covers the paged compositor too.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:01:58 -04:00
Brandon Thomas 4832e08aad PSD fidelity: 16/32-bit colour modes, Lab, Levels, Exposure, Dissolve oracle (corpus 133 → 140, psd-tools 212 → 222) (#202) (#202) 2026-10-05 08:42:05 -04:00
Brandon Thomas 574d0d3d53 PSD corpus: psd-tools set (pinned, verified), an oracle for every file, and the fixes it found (#195) (#195) 2026-10-05 08:01:39 -04:00
Brandon ThomasandClaude Opus 5.5 8877a812f9 GPU compositor: page layer surfaces beyond 16384 px with a memory budget (#187) (#187)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:49:38 -04:00
MikeeI a65b5ca80f fix(compose): mix group coverage in premultiplied space (#108) 2026-10-05 01:00:31 -04:00
Brandon ThomasandClaude Opus 5.5 37c8f81032 Image › Adjustments overhaul: full params, one editor per kind, live-preview dialogs, Curves point deletion (#68)
* Adjustments: full parameter sets for every kind, Hue/Sat ranges, CMYK/Lab tone channels

- doc: Hue/Saturation gains six colour ranges (HueRange), Gradient Map a dither flag,
  Levels/Curves a ToneSpace (RGB, CMYK inks + black, Lab) for mode-native channels.
- engine: new adjust_params module (from_params over a base / to_params) used by
  image.adjustments.*, layer.newAdjustmentLayer.* and layer.setAdjustment. Curves,
  Color Balance, Channel Mixer, Black & White, Gradient Map and Photo Filter now honour
  their params; bad params are BadParams errors; setAdjustment merges (empty = reset).
- compose/gpu: hue-range tables, CMYK/Lab tone channels (CPU; GPU falls back), dithered
  Gradient Map, Black & White from grey/secondary/primary parts; GPU parity extended.
- io: hue2 ranges, grdm dither, CMYK/Lab Levels/Curves records round-trip.
- destructive adjustments use the document's tone transfer.

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

* Adjustments UI: one editor per kind for Properties and Image › Adjustments dialogs

- adjust_editors: Brightness/Contrast (legacy), Levels and Curves (per channel in RGB, Gray,
  CMYK and Lab, histogram behind), Exposure, Vibrance, Hue/Saturation (master + six ranges with
  draggable range sliders, colorize, before/after spectrum), Color Balance (tones, preserve
  luminosity), Black & White (six weights, tint colour), Photo Filter (presets or colour,
  density), Channel Mixer (outputs, constant, monochrome, total), Posterize, Threshold
  (histogram), Gradient Map (stops, presets, reverse, dither). Replaces the M7 placeholder.
- Properties previews live (GPU) while a control moves and commits one setAdjustment per gesture.
- adjust_dialog: Image › Adjustments opens the same editor modally; the canvas previews the
  real command on the proxy, Preview checkbox, OK = one history step, Cancel leaves the doc.
- Curves (#43): hit-test at the press origin, Delete/Backspace removes the selected point when
  the graph has focus, Cmd/Ctrl-click removes, dragging off removes (and back on restores),
  arrows nudge; endpoints and the last two points stay; pressing the gradient bars does nothing.
- tone: histograms per channel space, of the image below a layer or of the target layer alone.
- kittests: every adjustment layer kind edited through the Properties panel with real input at
  8/16/32-bit RGB, Gray, CMYK and Lab (#12); Curves point interactions; dialog preview/OK/Cancel.

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

* io tests: adjustment round trip covers the new Levels/Curves/Hue-Sat/Gradient Map fields

#60's round-trip tests predate #68's doc-model additions (tone space and black channel,
Hue/Saturation ranges, Gradient Map dither); fill them with their defaults.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:08:40 -04:00
Brandon ThomasandClaude Opus 5.5 d5eb44e9fd Colour-managed canvas: document profile -> monitor (P3/Adobe RGB/ProPhoto/Gray/CMYK), linear EXR, embedded CMYK (#66)
* CMYK documents display and export through their embedded CMYK profile

A thread-local CmykSpace (photocraft_color::convert::with_cmyk_space) replaces the built-in
coated CMYK while the CPU compositor, the GPU compositor's uploads and the PSD merged
composite convert a document's CMYK pixels; GPU residents track the profile they were
converted with.

Refs #46

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

* EXR/HDR are linear: tag imports linear sRGB, linearise exports

Refs #46

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

* Colour-managed canvas: document profile -> monitor profile on GPU and CPU paths

* engine display_color: CanvasDisplay per (document profile, mode, monitor), identity fast
  path when they match (sRGB on sRGB costs nothing); linear composites are stored
  sRGB-encoded in the 8-bit texture. canvas_lut now always carries the CM transform, with
  Proof Colors / Gamut Warning / 32-bit preview on top.
* GPU canvas applies the display LUT whenever the transform isn't the identity (also for
  filter/proxy previews); the CPU canvas and Navigator transform the 8-bit composite.
* Edit > Color Settings > Monitor Profile (auto|built-in|.icc). macOS 'auto' reads the main
  display's ICC profile at launch via osascript/AppKit (no unsafe); elsewhere sRGB.
* Tests: P3 (hand-computed), Adobe RGB, ProPhoto, Gray Gamma 2.2, synthetic CMYK, linear,
  monitor setting; GPU+CPU canvas test via kittest (skips without Rgba32Float).

Fixes #46

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

* io: tag EXR/HDR linear in import_flat (adapt to the shared image_to_document)

#65 moved flat decoding into image_to_document(name, img) so camera raws can reuse it; the
linear-EXR tagging needs the file bytes for format detection, so it now runs in import_flat.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:44:13 -04:00
Brandon ThomasandClaude Opus 5.5 f8f48db78a Never crash: the GPU compositor is panic-free (Rule 9) (#55)
- A failed readback mapping (e.g. device lost) now returns Unsupported,
  so the caller falls back to the CPU compositor, instead of
  expect("mapped readback buffer").
- Pipeline creation skips kernels without an entry point instead of
  expect; resident/effect entries are looked up with let-else after the
  insert; effect-program targets are Option instead of indexing +
  expect; copies without a source are skipped; the planner returns
  Unsupported instead of unreachable! for an adjustment layer.
- gpu denies unwrap/expect/panic/unimplemented/todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:39:48 -04:00
Brandon ThomasandClaude Opus 5.5 66d5a5381e GPU: adapters without float render targets keep the GPU path for effect-free documents
Once Format stopped failing first, the Linux runner reached the GPU tests
for the first time: its software adapter (GL) can't render Rgba32Float or
R32Float. The app already picks Rgba16Float there, but the effect-map
pipelines (R32Float/R16Float) still panicked. They are now built inside an
error scope; where they can't be, documents with layer effects use the CPU
compositor and everything else stays on the GPU.

The exact-parity tests skip on adapters without 32-bit float targets (the
app uses the CPU compositor there), like they already skip without an
adapter; the fallback-path tests build with try_new_with_format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:55:25 -04:00
Brandon ThomasandClaude Opus 5.5 71272d5cd7 GPU: fs_adjust compiles under FXC (D3D12); a pipeline build failure falls back to the CPU
On D3D12 without DXC, wgpu compiles HLSL with FXC, which aborted on
fs_adjust ("internal error: compilation aborted unexpectedly"). wgpu turns
that into a panic, so every GPU test failed on the Windows runner, and the
app would crash where the same path is taken. A bisect on the Windows
runner pinned it to two cases of adjust()'s switch, Exposure and Selective
Color: both looped over dynamically indexed vector components. They are
now written per channel / per range with the same math (GPU-vs-CPU parity
is unchanged and passes on Metal and on the Windows runner).

Compositor::try_new_with_format catches validation and internal errors
while building the pipelines and returns Err; the canvas then uses the CPU
compositor (once, remembered) instead of crashing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:33:15 -04:00
Brandon ThomasandClaude Opus 5.5 f7eb6ea9e7 Format: adopt the craft-family rustfmt.toml and format the workspace
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:31:17 -04:00