Commit Graph
77 Commits
Author SHA1 Message Date
zenjabbaandzenjabba 95dbb01ad9 fix(gpu): preflight compositor uniform buffer limits (#3931)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-12 18:44:09 -04:00
fd21e73411 Format: check pattern sizes on load (#3478) (#3484)
* Format: check pattern sizes on load (#3478)

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

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

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

* gpu: skip unpaintable patterns in the Bevel texture too

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:06:16 -04:00
cbcdeb2549 Layer styles: Photoshop's measured contour presets; Satin's contour lookup (#3803)
* fix(effects): Photoshop's measured contour presets; Satin reads contours from the far end

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:52:57 -04:00
Jakub 3e428e7302 perf(gpu): a duplicated layer's texture starts as a GPU copy of its source's (#3848)
A duplicate shares its source's tiles (Arc), but its resident texture was new, so the
compositor converted and uploaded every tile again: duplicating a 20 MP photo layer uploaded
330 tiles. When a new resident's surface shares tiles with another resident of the same role,
region, texel kind, format and default pixel, its texture now starts as a copy of that one
(Stats.tiles_shared counts the tiles taken) and the usual per-tile pass fixes up whatever
differs. Layer textures gain COPY_SRC.

The copy is recorded in the frame's encoder, after whatever brought the donor up to date this
frame. Queued writes land before the encoder runs, so both textures are marked `ordered` and
their writes go through the encoder from then on; otherwise a write to either in the same
refresh would be overwritten by the copy or leak into it. A resident whose region only moved
still keeps its own texels (keep_overlap) first.

Duplicating a 20 MP photo layer in perf_scenarios: 25.1 -> 6.3 ms, 330 tile uploads -> 0
(RTX 3090, Vulkan). Paste is unchanged: the pasted layer has new tiles.

Test: duplicates_copy_their_source_texture covers a plain duplicate (no uploads), a
duplicate and its source each painted with one tile in the same refresh, and a duplicate
missing a tile, all against the CPU composite. Without the `ordered` routing the second case
is off by 167.6/255.
2026-10-12 08:46:28 -04:00
Jakub 9ff6c57e03 GPU composite: submit every 1000 copies and render passes, not only between page cells (#3844)
The compositor submitted only between 2048x2048 page cells, so one cell of a deep stack
recorded every pass into a single command buffer: 700 full-size layers make 1403 render
passes, and wgpu-core opens two hal command buffers per pass. Metal allows 4096 command
buffers per queue, so a few thousand passes in one cell can lose the device.

run_cell now counts copies and render passes and, when a refresh may submit in between,
submits the batch after FLUSH_OPS (1000) of them, waiting for the previous batch as the
page-cell flush does. Chunk textures, uniforms and bind groups outlive the submit and the
queue keeps the order, so a chunk continues in the next command buffer. A device lost in
such a submit ends the refresh with an error instead of recording more work.

Not measurably slower on Vulkan (RTX 3090): perf_scenarios rows are within noise.

Test: many_layers_submit_inside_one_chunk (700 noise layers on a 64x48 document) needs
flushes > 0 inside one chunk and matches the CPU composite; main records all 1403 passes
without a flush.
2026-10-12 08:46:16 -04:00
MrCloudy2andClaude Opus 5.5 7890b277a0 fix(adjust): absolute Selective Color scales by the room the ink has (#3793)
Photoshop's absolute mode scales each range's ink change by how much the
ink can still move: adding ink by min(1, 2 x (1 - ink)), removing it by
min(1, 2 x ink). PhotoCraft added the full percentage and clipped, so
Neutrals Cyan +50 darkened a grey 64 to 0 instead of 32. Measured in
Photoshop 27.11 over every range x ink at +-50 (72 cases): all within 1
level now (8 of 72 within 3 before). CPU and GPU paths.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:45:09 -04:00
MrCloudy2andClaude Opus 5.5 c5da1a0d5f Hue/Saturation: Photoshop's saturation curve, lightness order and Colorize lightness (#3781)
* fix(adjust): Hue/Saturation saturation, lightness order and Colorize as in Photoshop

Measured in Photoshop 27.11:
- Raising Saturation divides it by (1 - s): +50 doubles it. PhotoCraft
  multiplied by (1 + s), up to 26 levels off.
- Master Lightness applies first; hue and saturation then work on the
  result. Fixes the corpus file adjustments/rgb8/hue-saturation (Hue
  +40, Saturation +25, Lightness -10): 17 levels / 11.8 % bad -> 2 / 0 %.
- Colorize builds the colour at the lightened lightness, so black with
  Lightness +30 is a 30 % lightness colour, not grey (was 78 off).
- A colour range's positive Lightness lightens towards the pixel's
  brightest channel, not towards white (Blues +50 was 71 off).

All 18 Hue/Saturation probe cases are within 4 levels. Corpus: the
Colorize files go to 1-3 levels (psd-tools huesaturation_colorize_rgb
120 -> 3). CPU and GPU paths.

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

* fix(adjust): a range's negative Lightness darkens towards the darkest channel

Measured in Photoshop 27.11 (Blues -50 / -100, Reds -50): (102, 128, 204)
at Blues -50 becomes (102, 115, 154), towards the pixel's darkest channel,
not towards black. Mirrors the positive rule (towards the brightest).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:49:16 -04:00
MrCloudy2andClaude Opus 5.5 836fd52089 fix(adjust): Black & White tints like the Color blend mode, as Photoshop does (#3780)
Photoshop's Black & White tint keeps the grey's luminosity and takes the
tint's hue and saturation (SetLum + ClipColor, luma 0.30/0.59/0.11), the
same as the Color blend mode. PhotoCraft mixed half the grey with twice
the grey times the tint, up to 141 levels off. Measured in Photoshop
27.11 on 9 cases (4 tint colours over a grey ramp and hue sweeps): all
within 3 levels now. Corpus: photoshop adjustments/rgb8 and rgb16
black-white-tint go from 69 levels max / 77 % bad pixels to 1-3 / <=0.5 %.
CPU and GPU paths.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:49:05 -04:00
MrCloudy2andClaude Opus 5.5 56c5fd5797 fix(effects): Satin at distance 0 draws as 1 px, as Photoshop does (#3744)
Photoshop 27.11 draws Satin Distance 0 as a 1 px offset (an edge band of
2/size), not as two copies that cancel out. PhotoCraft drew nothing. Measured
on a #808080 square with a Linear contour at sizes 5, 10 and 20; the other 12
cases already matched within 1 level. Applied to the CPU and GPU paths and
to the effect reach.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:48:14 -04:00
Brandon ThomasandClaude Opus 5.5 27f56eb762 feat(compose): Lab documents evaluate Invert, Posterize, Threshold, Brightness/Contrast and Gradient Map on L, a, b (#4038)
In Lab documents Photoshop evaluates these adjustments on the Lab channels,
not on the screen RGB colour:

- Invert and Posterize act on L, a and b alike (normalised L/100,
  (a+128)/255, (b+128)/255; Invert is n -> 1 - n).
- Brightness/Contrast (modern and legacy) applies its tone curve to L only;
  a and b are unchanged.
- Threshold splits on L alone and outputs neutral black or white.
- Gradient Map is driven by L (Reverse uses 1 - L).

Adjustment layers (CPU compositor) and destructive Image > Adjustments both
go through the new `adjust::apply_in`; the GPU compositor defers these
adjustments to the CPU in Lab documents. Levels and Curves keep their Lab
records; other adjustments stay on the screen colour.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:20:44 -04:00
MrCloudy2andClaude Opus 5.5 8b21621671 Chisel bevels: Photoshop's facet slope and ramp ends (#3595)
Measured on Photoshop 27.11 (scripted: a 128 px square with one Bevel &
Emboss, light from the top at 30°, sampled pixel by pixel across its
edges; Chisel Hard and Soft, inner and outer, size 10 and 20, depth 100
and 300):

- A chisel facet's slope is depth × size / (2 (size + 1)): it rises to
  the same depth × size / 2 as a Smooth bevel, over size + 1 px.
  PhotoCraft gave it the full depth, about twice as steep (facet 217
  where Photoshop shows 188).
- Inner Hard's ramp runs from half a pixel beyond the edge to size + ½,
  so the facet is flat right up to the edge pixel; Inner Soft starts a
  pixel further out (a steeper edge pixel) and is not blurred; Outer
  Hard ends a pixel further out; Outer Soft is a pixel shorter with a
  steeper edge pixel. Outer chisel bevels also shade the pixel where
  their ramp reaches zero (it still slopes), as emboss styles already
  did.

All six inner and outer chisel cases are now within 1 level (they were
27 to 154 off); Smooth bevels are unchanged. Emboss and Pillow chisel
are not modelled yet (they keep the blur).

On the GPU: the same ramps in fs_mbevelh (Chisel Soft flag in p0.z), the
emboss-only blur, and the outer reach-past flag in p3.x; the 44 GPU
parity tests pass. Corpus: every Photoshop chisel oracle improves
(bevel-inner-chisel-hard 20.1 -> 6.5 % differing pixels, outer-chisel-
hard 13.6 -> 5.2 %, inner-chisel-soft 22.3 -> 9.3 %, emboss and pillow
chisel a few points); pass counts unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:43:01 -04:00
MrCloudy2andClaude Opus 5.5 97c6769d6b Glows: Photoshop's precise ramp, and centre-source glows inverted after the contour (#3578)
Measured on Photoshop 27.11 (scripted: a 128 px square with one glow,
sampled pixel by pixel across its edges; outer and inner, both
techniques, sizes 10 and 20, spread 0/40 %, choke 0/30 %, Range 50 %):

- A Precise glow is 1 - D / (size + 1) beyond the solid spread/choke
  part, D being the centre-to-centre distance to the shape's edge.
  PhotoCraft's ramp ended at `size` and the default Range then doubled
  it, so it stayed solid for half the size and fell off steeply: up to
  122 levels off. The raw coverage is now 0.5 at the edge falling to 0
  at size + 1, so the shared Range step (v / range) gives Photoshop's
  ramp at the default 50 %; other Range values are unmeasured.
- A centre-source inner glow is exactly 1 - the edge glow, taken after
  the contour and Range. PhotoCraft inverted before them: the softer
  centre glow was 115 levels off, the precise one 64.

All 16 glow cases are now within 2 levels (10 were 58 to 122 off);
softer edge and outer glows are unchanged. On the GPU: the same ramp in
fs_mglow, and an "invert after the contour" mode (p0.y = 2) in
fs_mfinish for glows only (Satin keeps its own invert). The 44 GPU
parity tests pass. Corpus: the two Photoshop precise-glow oracles go
from 0.31 / 0.37 to 0.05 worst error (curved anti-aliased shapes,
still not under 2/255); nothing else changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:32:33 -04:00
MrCloudy2andClaude Opus 5.5 3794ad471f Color Balance: Photoshop's per-channel levels and gamma curves (#3443)
Color Balance added a share of each slider weighted by the pixel's
lightness, then (Preserve Luminosity) rescaled to the old luma. On the
corpus's Photoshop-made file (photoshop/adjustments/rgb8/color-balance.psd,
all three tones moved, Preserve Luminosity on) that was up to 43 levels
off on 99% of the pixels.

Measured on Photoshop 27.11 (scripted: a gray ramp and 16 pure colours
through each slider at ±25, ±50 and ±100, with Preserve Luminosity on
and off, plus two and three tones and two sliders at once), every
slider is a levels-and-gamma curve on each channel:
- Preserve Luminosity off: midtones is gamma 2^-a on the slider's own
  channel; shadows gamma 2^(-a/2), after a black point at 0.39·|a| when
  lowered; highlights gamma 2^(-a/2), after a white point at 1 - 0.39·a
  when raised.
- Preserve Luminosity on: midtones gamma 2^(-a/2) on its channel and
  2^(a/2) on the other two; shadows and highlights go by the differences
  between a tone's sliders: a black point at 0.39·(max - own) and a
  white point at 1 - 0.39·(own - min) per channel.
- Tones apply highlights, shadows, then midtones.
All 88 measured cases match within 1 level, except midtones +100 below
about 28 of 255 (Photoshop flattens the steepest gamma near black, by
up to 9 levels). The corpus file is now within 4 levels (mean 1.4, was
20.9).

The GPU builds the same curves into per-channel tables and uses the
Levels/Curves kernel; the old Color Balance shader case is gone.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:45:58 -04:00
alexanderalber 980cf05be4 Group effects follow their children's effects, not just their pixels (#3274) (#3326)
A group's effect region and the empty-tile check were computed from
layer_bounds, which for a group is the union of its children's pixels.
A child's outside stroke reaching past that region was cut off on the
canvas (GPU) and in tiled CPU renders, and the group's drop shadow
followed the cut shape.

New photocraft_compose::effect_source_bounds: for a group, the children
grown by their own effect reach. Used by compose::effect_region,
compose::empty_in and gpu::bounds::effect_region.

generated with Claude Code
2026-10-11 22:20:23 -04:00
aba9b2b4ef Curves: pencil mode, Smooth and Edit points (#3088) (#3108)
* Curves: pencil mode, Smooth and Edit points (#3088)

The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.

- doc: Adjustment::Curves gains `pencil` (serde default), with
  curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
  256-level maps; switching modes draws the spline into maps or fits up to
  16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
  as padding, so pencil curves from Photoshop were misparsed; they are now
  read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.

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

* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:05:58 -04:00
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