Commit Graph
41 Commits
Author SHA1 Message Date
fb924dad16 Shapes: a hairline vector stroke no longer darkens the shape's edge pixels (#397)
* Shapes: a hairline vector stroke no longer darkens the shape's edge pixels

A stroked shape with layer effects (or clipped layers) is drawn as our
fill plus a vector stroke fitted to Photoshop's own pixels: where those
pixels reach past our fill, the stroke takes the alpha the fill doesn't
explain, `(pixels - fill) / (1 - fill)`. It kept our stroke's colour for
that alpha. When Photoshop's fill covers more of an edge pixel than ours
(shapes snapped to whole pixels with Align Edges, our path a fraction off
the grid), the fitted alpha is mostly Photoshop's fill, so a 0.25 px grey
border on a white card was painted as a solid grey line: the edge came out
at the stroke's full colour (122) instead of Photoshop's 220.

The fitted stroke now takes the colour that, drawn Normal over our fill,
gives back Photoshop's pixel (un-compositing the premultiplied colour),
and keeps our colour only where the fitted alpha is negligible. Where
Photoshop's pixels agree with our fill this is our stroke's colour, as
before.

Test: hairline_vector_stroke_keeps_photoshops_edge_pixel_colour (edge
pixel 0.478 before, 0.86 after). The compose, gpu, io and engine tests
and the psd/io/engine corpus tests pass.

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

* Shapes: neutral wording in the hairline-stroke fix; test it at 8, 16 and 32 bits

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

---------

Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
2026-10-07 13:50:09 -07:00
Alex GorevskiandCopilot 0c40ec17c6 fix(color): match Photoshop blend corner values (#699)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:00:27 -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 5ada60c35b Compositor: effect maps never block inside parallel tiles (fixes #276 deadlock) (#281) (#281) 2026-10-05 20:07:16 -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
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 27e047f19f Fix flaky canvas_geom undo test: isolate the text-gamma test in its own binary (#135)
blend_text_gamma_setting switched the compositor's process-wide text gamma
(psblend::set_text_gamma) to 1.0 and back while other engine unit tests ran
in parallel threads. turns_at_every_depth_and_mode flattens a document with
a type layer before a turn and again after edit.undo; when the toggle landed
between the two flattens, anti-aliased glyph edges blended under different
gammas and the bit-exact comparison failed (seen once on Windows CI).

Move the gamma test to crates/engine/tests/text_gamma.rs (its own process),
document the hazard on set_text_gamma, and make the undo assertion report
the turn, depth, mode, differing pixel count, max difference and first pixel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:36:37 -04:00
MikeeI ad7046bf99 fix(compose): scale artboard bounds in proxy documents (#131) 2026-10-05 01:25:06 -04:00
MikeeI 7c53a3c1ad perf(compose): share bounded pattern preparation across tiles (#104)
Reserve up to 64 MiB of converted pattern pixels and 64 entries for
one render call, including pending initializers and all export bands.
Initialize outside the map lock on the requesting CMYK-scoped worker.

Budget exhaustion retains the existing uncached conversion path;
the retained-payload budget is not a cap on transient fallback buffers.
2026-10-05 01:01:23 -04:00
MikeeI a0a90c3c15 perf(compose): reuse compiled vector masks within each render (#112)
Keep OnceLock geometry initialization sequential so Rayon work stealing
cannot re-enter an unfinished slot. Raster coverage and scratch remain
local to each requested rectangle; export bands share only geometry.
2026-10-05 01:01:10 -04:00
MikeeI fb79564f2a perf(compose): remove redundant effect application halos (#110)
Keep full-region neighbourhood effect maps while rendering content only for the output rectangle. Compare complete tiled output at U8, U16 and F32 depths rather than three sampled pixels.
2026-10-05 01:00:44 -04:00
MikeeI a65b5ca80f fix(compose): mix group coverage in premultiplied space (#108) 2026-10-05 01:00:31 -04:00
MikeeI 4af95fa807 fix(compose): hash surface defaults in effect cache identity (#105) 2026-10-05 01:00:07 -04:00
MikeeI d3a6855367 perf(compose): prepare gradient stops outside pixel loops (#103)
Keep effect and fill sampling separate: empty effects use a gray ramp,
while empty fills stay transparent and interpolate Color.alpha.
Preparation stays inside the active document CMYK scope.

Evidence:
- 24 MP RGB gradient overlay: warm median 362.752 ms to 235.813 ms.
- 24 MP RGB gradient fill: warm median 129.264 ms to 117.642 ms.
- Five release samples, eight Rayon workers, matching pixel digests.
2026-10-05 00:59:53 -04:00
MikeeI 5ede5f7ba6 perf(compose): avoid unused backdrop snapshots (#109)
Pass-through groups snapshot only for mixing or visible clipping. Adjustments retain one independent mutable result and read the untouched original backdrop pixel before replacing it.
2026-10-05 00:59:38 -04:00
4f8da9365a fix(ui): brush and eraser show the real stroke while drawing (#64)
* fix(ui): brush and eraser show the real stroke while drawing

While dragging, the Brush and Eraser drew a hard, flat egui polyline at
the brush width; the soft brush only appeared on release.

The engine's new `brush_cmds::LiveStroke` shares `paint.stroke`'s setup
and renders incrementally with `StrokeRenderer` onto a copy of the
document. The canvas shows that copy and recomposites only each step's
damage (preview key = stroke key + step). The commit passes the live
stroke's seed, so its pixels match the preview, and reuses the canvas so
only the stroke rect refreshes.

Downsampled CPU textures (documents over 4096 px, the Navigator) now
update partially too, aligned to the downsample factor, instead of
re-flattening the whole document.

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

* Live stroke on the large-image stack: reduced CPU textures, pen tilt, display key

Adapts #64's live Brush/Eraser stroke to #71 (banded/reduced canvas), #66
(colour-managed display) and #74 (stylus tilt):

- compose: `render_reduced_damage` renders just the output pixels of
  `render_reduced` whose source area meets a damage rect, with bit-identical
  values (same spans, same summation order). The CPU canvas texture of a
  document over 4096 px now updates partially from it (strokes, live steps,
  any damaged edit) instead of #64's factor-aligned `downsample`, which
  didn't match #71's area-reduced texture; uploads still go through
  `display_image` (document -> monitor profile).
- ui-egui: the live stroke feeds each point's pen tilt/rotation (the same
  values `Stylus::stroke_points` gives the commit), so tilt-driven brushes
  preview what they commit; the first points come from the drag after
  `Stylus::begin_stroke`.
- Preview keys keep #66's display key folded in; `damage_since` un-folds
  the cached keys to compare against the live stroke's steps, and the commit
  resets the caches to the document's key (raw 0 ^ display key).
- Stroke counter is masked so live keys stay in their 2^44 band.

6000x4000, 120 px soft brush, release: LiveStroke::push 0.72 ms mean
(p95 1.0), GPU damage refresh 1.6 ms mean (p95 4.7); CPU reduced-texture
rect 0.75 ms vs 135 ms for the full 3000x2000 reduction it replaces.

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-04 17:10:23 -04:00
Brandon ThomasandClaude Opus 5.5 d3b540234c Large images: adapter texture limits, banded compositing/export, proxy thumbnails, history byte budget (#71)
* Compose: render in place and in bands; area-reduced and proxy thumbnails

render_tiled wrote every tile into a Vec and then copied them into the output:
two full-size float copies (6.3 GB for a 14000² document). Tiles are now
written straight into the output band by band.

New streaming APIs so large documents never need a full-size float
composite: render_bands (sink per band of ~8 MP), render_reduced (banded
area average, bit-identical to the old flatten-then-average thumbnail) and
flatten_to_surface (Flatten Image, Merge Visible, Copy Merged and the other
flattened-surface users).

thumbnail() reduces a downsampled proxy (moved from ui-egui to
compose::proxy, now also scaling spot channels) for documents over 16 MP
when that is faithful (no layer effects or vector masks), so navigator,
channel and histogram thumbnails of a 196 MP document cost milliseconds.

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

* GPU canvas: request the adapter's real limits; banded CPU fallback; navigator thumbnail

egui-wgpu asks for an 8192 px texture limit, so the wgpu compositor rejected
every document wider or taller than 8192 px and each refresh fell back to a
full CPU flatten. The desktop and web apps now request the adapter's own
max_texture_dimension_2d and max_buffer_size (gpu_canvas::use_adapter_limits);
the benches use the same limits.

The CPU fallback (documents beyond the GPU limit, or features it lacks) is
GpuCanvas::refresh: it renders and uploads the composite band by band
instead of a full float composite plus a full RGBA8 copy. The CPU canvas
texture is reduced in bands too, and the Navigator on the GPU canvas uses a
cached thumbnail instead of a full-resolution CPU composite per revision.

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

* IO: banded export and import; no full-size copies in encoders; larger decode limits

- Flat export renders the composite in bands and quantises each band; alpha
  is dropped in place when every pixel is opaque. The single-layer native
  path copies surface rows a band at a time (no f32 read_region and no
  per-pixel Vec). Buffers come from try_reserve: Err, not an abort.
- PSD export encodes the merged image band by band into its planes (no
  flatten, no second plane copy); near-opaque documents are re-encoded
  without the white matte so the output is unchanged. Layer interleaved
  bytes are freed before their planes are compressed.
- Flat import converts in ~32 MB bands (and not at all when already RGBA);
  the PSD merged-image un-matte works per tile row.
- Encoders borrow the image when no conversion is needed (Image::converted)
  instead of cloning it; PNG only copies 16-bit data for the byte swap.
- Decode limits: 2^30 pixels and 8 GiB on 64-bit targets (a 20000² JPEG
  was refused at 2^28).

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

* Filters: write tile results in memory-bounded groups

apply_tiled collected every tile's float result before writing any (3.1 GB
for a 14000² layer). Results are now produced and written in groups of about
256 MB (at least one tile per core).

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

* History: bound pixel memory by Preferences > Performance > Memory Usage

History::max_bytes caps the current document's tiles plus the tiles only
history holds (layers, masks, alpha channels); History::trim drops the
oldest states beyond it after every recorded step and when preferences
change, always keeping the last step undoable. The engine sets it from
memory_usage_mb, which was unused.

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

* Large-image benchmark (14k / 20k): open, refresh, thumbnails, filter, stroke, saves

One operation per run on a cached synthetic photo, with the GPU device created
the way the app creates it, so /usr/bin/time -l reports that operation's peak
memory. Documented in docs/development.md.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:45:17 -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 51b82caf0e PSD round trip: read/write Vibrance, Color Balance, B&W, Photo Filter, Channel Mixer; corpus floors (#60)
* PSD: read and write vibA, blnc, blwh, phfl, mixr adjustment layers

Vibrance, Color Balance, Black & White, Photo Filter and Channel Mixer
layers made in PhotoCraft were dropped on PSD save (Photoshop saw an empty
pixel layer), and the same blocks rendered as no-ops on import. They are
now parsed into their Adjustment variants and written per the Adobe spec
(descriptor blocks with version 16; phfl version 2/3; mixr version 1).
A Color Lookup without a table is written as an identity cube and a
Gradient Map with fewer than two stops as the equivalent two-stop
gradient, so every evaluated kind has an encoding.

Tests: golden hand-built blocks for each new key, malformed and fuzzed
input, and a document -> PSD -> document round trip of every adjustment
kind (8 and 16 bit, plain / masked + blended / clipped) and every blend
mode (pixel, clipped, group, pass-through, adjustment layers), asserting
model equality where lossless and composite equality within 1/255.

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

* Corpus: export round-trip step and pass floors; fill pixels in the mask frame

corpus.rs now re-imports every exported file and compares its flatten
with the first import (169/169 render the same). With PHOTOCRAFT_CORPUS
set (corpus dir, or 1 for the default location) it asserts the oracle
pass floor (113) and the round-trip floor (169).

The new step found gradient fills without Photoshop pixels exported in
the canvas frame instead of the frame their masks give them, so they
re-imported shifted (gradient-fill.psd/.psb, 0.26 max error). Export now
renders the fill like the compositor (render_fill_content).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:25:00 -04:00
Brandon ThomasandClaude Opus 5.5 296ced38d6 Never crash: text, color, doc, paint, geom, raster, compose, vector (#39)
- Real crash: build_engine_data took its template from the PSD's text
  engine data and did expect("EngineDict"), so a text layer whose engine
  data lacked EngineDict (or wasn't a dictionary) crashed PSD export.
  The template is now repaired; regression test
  engine_data_template_without_engine_dict.
- color: the built-in CMYK<->sRGB transforms fall back to the naive
  formulas instead of expect if they ever fail to link.
- doc: insert_above falls back to the top of the root instead of
  expect("valid path"); next_layer_name searches a bounded range.
- paint: Mips::new loops with while-let instead of last().expect.
- All eight crates deny unwrap/expect/panic/unimplemented/todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:25:44 -04:00
Brandon ThomasandClaude Opus 5.5 1c09936154 Clippy 1.99: chunks_exact with a constant size -> as_chunks; drop a no-op Option filter
Rust 1.99's clippy adds chunks_exact_to_as_chunks and some_filter, which
broke the Clippy step on every CI runner (it uses the latest stable). All
sites are mechanical: as_chunks drops the remainder exactly like
chunks_exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:33:14 -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
11e5af23ad Layer Style › Blending Options › Blend If (#26)
Model Blend If (This Layer / Underlying Layer ranges, split points) as
Layer::blend_if, read and write it as the PSD layer-record blending
ranges (previously parsed but dropped on export), save it in .pcraft,
render it in the CPU compositor (premultiplied mix = compositing at
k × alpha), fall back to the CPU on the GPU path, and drive it from
layer.layerStyle.blendingOptions {"blendIf": …}; document.inspect
reports it.

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:27 -04:00
Brandon ThomasandClaude Opus 4.8 b6b8ed21cb Levels: Photoshop's gamma>1 shadow toe (bounded slope 2^gamma)
Verified our levels() against Adobe Photoshop 2026 (adjustLevels on a 0..255
ramp): exact for gamma<=1 and all input/output ranges, but a pure u^(1/gamma)
has infinite slope at black while Photoshop bounds the shadow toe to initial
slope 2^gamma. Added a soft-min (p-norm) of the power curve with that line,
applied only for gamma>1; reduces to the exact power curve in the body, so
highlights/midtones and gamma<=1 are unchanged. GPU Levels LUT is built from
this fn, so GPU stays in sync (gpu parity 18/18).

Corpus oracle: levels_grayscale.psd max error 0.043 -> 0.0078 (bad px
4.1% -> 0.1%); 113 pass / 30 differ unchanged (no regressions). Golden test
pins the curve to captured Photoshop samples (gamma 2 toe within 4/255).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 09:58:31 -04:00
Brandon ThomasandClaude Opus 4.8 4759d12b57 Modern Brightness/Contrast: real Photoshop curves (CPU + GPU)
The non-legacy B/C was a linear scale that hard-clipped; Photoshop's modern
B/C is a pair of smooth curves that pin pure black and white. Reverse-
engineered from Photoshop 2026 ground truth (a 0..255 ramp pushed through the
app via executeAction BrgC, useLegacy=false):
  - Contrast: symmetric cubic-Hermite S pivoting at 0.5 (end slope 1-c/128,
    centre 1+c/128) -- near-exact, <=0.5/255 vs Photoshop.
  - Brightness: line of slope 1.375^(b/50) rolled off to (1,1) by a v^P white
    anchor -- close, approximate at the extremes (the real roll-off is a spline).
Applied brightness-then-contrast; matched in the wgsl compositor (mbright/
mcontrast) so GPU == CPU oracle (gpu parity 18/18). Golden tests pin both
curves to the captured Photoshop samples.

Corpus oracle: brightnesscontrast_grayscale.psd max error 0.478 -> 0.086
(bad px 91% -> 29%); 113 pass / 30 differ unchanged (no regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 09:11:44 -04:00
Brandon ThomasandClaude Opus 5.5 42266291d5 Fidelity, GPU fallbacks, interactive performance; print spool-file fix
PSD oracle 111 -> 113 (tent effect blurs, bevel height/shading, glow
range, spread rounding, text gamma, gradient span, Lab blending, colour
burn/dodge edges), channel restrictions (brst), bevel contour/texture,
chisel techniques. Vector masks, pass-through clips, artboards, pattern
fills and bevel textures render on the GPU (parity <=1/255). PNG export
7x, PSD save 4x, .pcraft save 3.6x faster; CPU effects refresh 11x;
deadlock in the effect-map blur fixed. interactive_bench example.

Print: unique spool file per call (concurrent prints of same-named
documents overwrote each other).

Workspace: 2,040 tests pass, clippy clean, layers + wasm OK, parity 600/625.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:26:34 -04:00
Brandon ThomasandClaude Opus 5.5 ceb2667ac4 Preset panels, character/paragraph styles, Glyphs, Check Spelling
Gradients/Patterns/Styles/Shapes/Tool Presets/Clone Source panels with
headless preset commands and a Custom Shape tool; Color card tabs match
Essentials. Character and Paragraph Styles (doc model, .pcraft, PSD style
sheets), Glyphs panel + type.insertText, Check Spelling (SCOWL-50 list).

Checkpoint also includes compiling in-progress work of the remaining agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:02:34 -04:00
Brandon ThomasandClaude Opus 5.5 aa87012aab Analysis, notes, proof views, workspaces (+ in-progress work of other agents)
Image > Analysis (ruler, count, measurement scale/log/record, scale
marker), notes (PSD Anno round trip, Notes panel, import), Modifier Keys
panel, Proof Setup plates / Legacy Mac RGB / colour blindness (Vienot),
32-bit preview options, custom pixel aspect, extras options, New/Delete/
Lock Workspace. Parity 600/625, FLOOR 595.

Checkpoint: also contains compiling in-progress work from the parallel
panels, type styles, File menu, photo, filters, UI-parity and fidelity
agents, plus photocraft-cli serve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:49:07 -04:00
Brandon Thomas aad724f28e work in progress 2026-10-01 10:07:04 -04:00
Brandon Thomas 6b44c393fa continued work 2026-10-01 09:16:17 -04:00
Brandon Thomas 536a7346fb more algorithms, progress 2026-10-01 08:25:06 -04:00
Brandon Thomas b00b24739a more work 2026-09-30 11:36:20 -04:00
Brandon Thomas a517477da8 readme, more impl 2026-09-30 11:15:03 -04:00
Brandon Thomas ae92ac20bf continued wip 2026-09-30 09:37:18 -04:00
Brandon Thomas 16941f7198 one-shot 2026-09-30 05:02:08 -04:00