33 Commits
Author SHA1 Message Date
Sir Starch 3260f6abb9 fix(psd): prioritize 16-bit and 32-bit global layer blocks over compatibility section (#4928) (#4950)
* fix(psd): prioritize 16-bit and 32-bit global layer blocks over compatibility section (#4928)

In 16-bit and 32-bit PSD files saved by Photoshop, the Layer Info section
either contains 0 layers or 8-bit compatibility layers, while the actual
16-bit/32-bit layers are stored in the global Lr16/Lr32 tagged blocks.

Previously, candidate_seen was initialized to layer_info.is_some(), which
caused PsdFile::from_bytes to skip the Lr16/Lr32 block whenever the section
was non-empty. This left the document with empty layer lists or 8-bit records
that failed channel decoding at depth 16/32, causing all layers to be empty
and invisible.

Prioritize the depth-specific tagged block (Lr16 for 16-bit, Lr32 for 32-bit)
over the compatibility section.

* test(psd): improve 16-bit multi-layer compatibility regression tests
2026-10-13 12:56:15 -04:00
Sir Starch 2ec1c8c0aa Themes: default light theme to Pro Light to preserve the Pro layout (#5016) (#5029)
When appearance mode was toggled between dark and light with default
settings, PhotoCraft switched from Pro Medium (Pro layout) to Studio Light
(Studio layout), altering the UI and displaying a Fit button in the status
bar.

Now that the Photoshop Pro light brightness levels exist (#2661), default
the saved light theme to Pro Light so the Pro interface layout and status
bar are preserved when toggling appearance. Also map the "light" alias to
Pro Light and list the Pro options first under Light Theme preferences.

Fixes #5016.
2026-10-13 12:02:32 -04:00
97d06b240b feat(ui): add hamburger menu to the Brush Settings panel (#4272) (#4358)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:24:09 -04:00
aa4d4859b4 fix(io): preserve indexed BMP color tables (#3802) (#3869)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:13:38 -04:00
Sir Starch 5bdb12ec51 fix(canvas): keep transform preview visible while panning (#3610) (#3843) 2026-10-12 05:14:27 -04:00
f14817a5d2 Animated GIF: every frame loads into video layers (#572) (#2999)
* feat(codecs): decode every frame of an animated GIF (#572)

decode_gif_frames returns each frame composited onto the logical screen
(disposal methods applied) with its delay in milliseconds. decode still
keeps the first frame only. A damaged later frame keeps the frames before
it and marks the first with DecodeWarning::Truncated; dimensions, the
total decoded bytes and a frame cap are checked against the limits.

* feat(io): import_gif_frames hands each GIF frame over as a document (#572)

A frame at a time becomes a single-layer document, so a caller can
flatten and drop it instead of holding every frame twice.

* feat(engine): animated GIFs load every frame into video layers (#572)

New Video Layer from File, Replace Footage, Reload Frame and Video Frames
to Layers take all frames of a GIF instead of the first. The timeline rate
comes from the GIF's delays (the most common one, delays under 20 ms shown
at 100 ms as browsers do); an explicit fps still wins.

* docs: animated GIF frames in video layers (#572)

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 01:26:37 -04:00
Sir Starch b4a949225d Support automatic monitor ICC profiles on Linux X11 (#2963)
* Linux X11: detect and apply monitor ICC profiles

* Test Linux monitor ICC profile handling

* Gate display detection test to supported platforms
2026-10-12 01:17:59 -04:00
Sir Starch e8c7656150 Use each XRandR output's ICC profile in the screen picker (#2962)
* Screen picker: use each XRandR output's ICC profile

* Test X11 screen picker ICC validation
2026-10-12 01:09:03 -04:00
Sir Starch 82c58fabab Use Fontconfig for Linux font discovery (#2330) (#2961)
* Fix #2330: discover installed fonts through Fontconfig

* Fix wasm cfg for Fontconfig fallback

* Add Linux font discovery regression tests

* Reject Fontconfig matches without CJK coverage
2026-10-12 01:05:56 -04:00
Sir Starch 04e7e7d7f8 fix(io): smooth PSD gradients no longer overshoot their stops (#3028) (#3285)
The Catmull-Rom tangents used for Smoothness are derived from the
neighbouring stops, so a segment between two equal-coloured stops (a
flat plateau) picked up a non-zero tangent and dipped below its own
colour. In the Gradient Map from #3028 the 27%-53% plateau (#1c1c1c)
rendered about 19/255 too dark.

Bound each interpolated channel to the range of the segment's two end
colours. Smoothness still bends the ramp, but never invents a colour
outside the segment, so plateaus stay exactly flat.

The regression test uses the four stop positions and grey values from
the issue (synthetic, no artwork) and fails without the clamp.
2026-10-11 21:14:28 -04:00
Sir Starch d9ecd60fb3 automation: deny edit.installedProfiles, it scans the OS profile folders (#3763)
edit.installedProfiles lists ICC profiles from the OS and user profile
directories, so automation must not run it, the same as the other
profile commands that read ambient paths. Without a policy the registry
check in workspace::tests fails, which has kept CI red on main since the
command landed.
2026-10-11 17:08:47 -04:00
Sir Starch 40d7e6a1d8 fix(history): read pixels from locked history states (#3076) (#3186) 2026-10-11 17:06:15 -04:00
Sir Starch 2bbe6c3fdc fix(filters): process untouched Reveal All masks (#3082) (#3156) 2026-10-11 16:59:55 -04:00
cc75ed52c1 fix(cli): skip recorded batch view and save steps (#3058) (#3143)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:54:24 -04:00
Sir Starch 06dcdc1267 fix(mode): restore indexed pixels after clearing transparency (#3080) (#3153) 2026-10-11 16:50:58 -04:00
88ffd89e9f fix(crop): unlock Background when extending canvas (#3081) (#3158)
* fix(crop): unlock Background when extending canvas (#3081)

* Crop: fill an extended Background with the background colour

Photoshop's Crop tool keeps the Background when the frame extends past
the canvas and fills the added area with the background colour, as an
opaque Canvas Size extension does. Do that instead of converting the
Background to a normal layer; other layers still get transparent pixels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:46:09 -04:00
f155374ac3 fix(type): navigate adjacent line with large leading (#3066) (#3141)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:38:14 -04:00
Sir Starch b66ebb84d2 fix(type): count normalized insert text caret (#3074) (#3138) 2026-10-11 16:31:51 -04:00
c234865b17 fix(type): respect Lock All on text edits (#3092) (#3132)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:26:10 -04:00
Sir Starch a893113502 fix(gradient): respect Lock All on Gradient Fills (#3093) (#3120) 2026-10-11 16:19:40 -04:00
Sir Starch f435eb0b55 fix(paint): respect Lock All on layer masks (#3094) (#3111) 2026-10-11 16:15:08 -04:00
d8d8d9f3d2 Tools: Convert Point tool in the Pen flyout (#3204) (#3211)
Convert Point is a tool of its own, built on the existing path.convertPoint
edit that Pen + Alt already drives. A click makes a smooth anchor a corner, a
drag from an anchor pulls out smooth handles, a drag on a handle moves it
alone. The tool selects the anchor it converts so its handles show; a press on
empty canvas or a segment does nothing, and Cmd/Ctrl is Direct Selection, as
with the Pen. It shares the Pen's key (P).

Original glyph with an ATTRIBUTION.md row; tool name in all catalogs; README
tool list and the G6 / ui-parity / roadmap counts updated (tools 56 of 68, the
earlier 53 was stale).

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:09:38 -04:00
Sir Starch bfa523f916 fix(theme): subscribe before reading Linux appearance (#2833) 2026-10-10 19:15:38 -04:00
Sir Starch 67e34a1d4c Themes: add Breeze Light and Breeze Dark (#2276) (#2335)
Two Studio-layout themes for KDE Plasma desktops: original colour values
chosen to sit close to Breeze's cool blue-grey surfaces and blue accent
(and its 5 px radius) so the app blends into a Plasma desktop, without
copying Breeze's colour schemes. Text readability comes first.

- theme.rs: ThemeKind::BreezeLight / BreezeDark. Every text colour (text,
  secondary, faint, accent, danger, warning) holds 4.5:1 on every surface
  and on hovered, pressed and selected states; the primary button is a
  deeper blue that takes white text and stays above 4.5:1 while it
  darkens on hover and press. Breeze's own inactive text and link blue
  are only about 2.8:1 on its light surfaces, so ours are darker. The
  close button goes red on hover.
- prefs: Theme/LightTheme::BreezeLight ("breezeLight") and
  Theme/DarkTheme::BreezeDark ("breezeDark"): Preferences > Interface,
  `prefs.set`.
- Window > Theme > Breeze Light/Dark Theme (`window.theme.breezeLight`,
  `window.theme.breezeDark`) and `ui.set {"theme": ...}` (aliases:
  breeze, kde, plasma for the light one; ...dark for the dark one).
- i18n: the four new labels in all 16 catalogs.
- docs/ui-design.md and ATTRIBUTION.md (original work, credits Breeze as
  the inspiration; Breeze's schemes are LGPL-2.0-or-later and none of
  their values, files or code are copied).
- Tests: names/aliases, surface order and blue-grey character, the
  contrast matrix for both themes, and the preference/mode selection.
2026-10-10 19:01:53 -04:00
Sir StarchandClaude Sonnet 5.5 e2aa19341f PSD export: pad regenerated fill blocks to 4 bytes, as Photoshop does (#2759)
Photoshop pads every layer-level tagged block to a multiple of 4 inside the length field; all 567 Photoshop-authored fixtures in the corpora do. The exporter padded regenerated blocks to even only, so a Pattern Fill's PtFl came out 2 bytes short of the boundary for half of all pattern names and left the layer record's extra data unaligned.

The pad is applied where the fill blocks are regenerated, not in the generic loop: preserved blocks must stay byte-identical (psd_structure, roundtrip) and shmd is length-structured (comps_artboards).

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-10 15:39:01 -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 <[email protected]>
2026-10-10 06:38:33 -04:00
Sir StarchandBrandon Thomas 7b3ded0f8b Layers menu and layer commands follow the selection, not the active layer (#2244) (#2254)
* Layer › Rasterize acts on every selected layer; the Layers menu offers Rasterize Layers (#2243)

Rasterize › Layer / Type / Shape / Fill Content / Smart Object took only the active
layer, so right-clicking inside a multi-layer selection converted one layer. Without a
`layer` param they now rasterize every selected layer they apply to, in one history step.
An explicit `layer` (the "Rasterize?" paint prompt, scripts) still targets exactly one.

Layers panel: a multi-layer selection gets one "Rasterize Layers" item (greyed only when
nothing selected can be rasterized, judged by the whole selection, not the clicked layer).
Edit Contents and Convert to Layers act on one layer, so they are not offered there.
Adobe's documentation: select "the layer or layers you'd like to rasterize".

Tests: engine integration (mixed type/shape/fill/smart selection, one undo step, per-kind
commands, explicit layer, nothing to rasterize, bad targets) and ui-egui (menu entries,
enablement, right-click → Rasterize Layers end to end). The new behaviour tests fail on
the old code.

* Layers menu and layer commands follow the selection, not the active layer (#2244)

Availability of Rasterize, Create/Release Clipping Mask, Add Layer Mask and Paste/Clear Layer
Style was decided by the active layer, so one selection was greyed or not depending on which of
its layers happened to be active (with the Background active, Create Clipping Mask and Add Layer
Mask were greyed although they would have run on the other layers), and Rasterize Layer was
enabled for layers it then refused ("nothing to rasterize"). Several of these commands also only
ever acted on the active layer, whichever layer the menu was opened on.

- Add Layer Mask (Reveal/Hide All, Reveal/Hide Selection), Paste Layer Style and Clear Layer Style
  run on every selected layer that can take them (not the Background, layers that already have a
  mask, layers without a style), in one history step. An explicit `layer`, or a single selected
  layer, behaves as before.
- `enable_rules::selection_targets`: availability of those commands, Create/Release Clipping Mask
  and Rasterize is judged by the selection.
- Rasterize Layer is enabled only for type, shape, fill and Smart Object layers, which is what the
  command converts.
- Items with no single target stay greyed for several layers: Blending Options, Copy Layer Style,
  Quick Export, Export As and the per-layer mask items. A multi-layer menu has one stable mask item.

Tests: engine integration (mask, paste and clear on a selection, errors, unchanged single-layer
paths) and a property test over 20 selections x clipboard state x 7 commands: an enabled item runs,
and for several layers a greyed one would have changed nothing. It fails on the old rules for
["R1", "BG"] with the Background active, the case from the bug report.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 04:18:42 -04:00
Sir StarchandBrandon Thomas 1b6150e3fc Layer › Rasterize acts on every selected layer; the Layers menu offers Rasterize Layers (#2243) (#2253)
Rasterize › Layer / Type / Shape / Fill Content / Smart Object took only the active
layer, so right-clicking inside a multi-layer selection converted one layer. Without a
`layer` param they now rasterize every selected layer they apply to, in one history step.
An explicit `layer` (the "Rasterize?" paint prompt, scripts) still targets exactly one.

Layers panel: a multi-layer selection gets one "Rasterize Layers" item (greyed only when
nothing selected can be rasterized, judged by the whole selection, not the clicked layer).
Edit Contents and Convert to Layers act on one layer, so they are not offered there.
Adobe's documentation: select "the layer or layers you'd like to rasterize".

Tests: engine integration (mixed type/shape/fill/smart selection, one undo step, per-kind
commands, explicit layer, nothing to rasterize, bad targets) and ui-egui (menu entries,
enablement, right-click → Rasterize Layers end to end). The new behaviour tests fail on
the old code.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 03:55:43 -04:00
Sir Starch 1f84f19caf Hand tool: an open hand and a fist as the pointer on Windows, and the fist while panning everywhere (#2196) (#2221)
* Hand tool: an open hand and a fist as the pointer on Windows, and the fist while panning everywhere (#2196)

Windows: winit maps CursorIcon::Grab and Grabbing to the four-arrow move cursor, so the Hand
tool never showed a hand. tool_cursor::hand now hands the OS an outlined bitmap hand (open) and
fist (dragging), drawn from signed distance fields like the brush tips, through the same
set_cursor_image path. macOS and the Linux themes keep their own grab cursors.

All platforms: the tool-cursor block of canvas_view is inside `else if primary`, which the Hand's
own drag branch skips, so Grabbing was dead code and the pointer was a plain arrow while panning
(Hand tool, Space and the middle button). The drag branch now sets the fist.

Also used by the Hand pointer under a dialog and the Camera Raw preview's pan.

* tool_cursor tests: the Hand's pointer on Windows is now a bitmap (#2196)

brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_changes pinned the old
behaviour (no bitmap, CursorIcon::Grab once the Hand is selected), which is the four-arrow move
cursor on Windows. It now expects the open hand's bitmap.
2026-10-10 03:15:00 -04:00
Sir StarchandBrandon Thomas 819bd47bf1 perf: bevel_bench, the cost of changing a chisel bevel's size (#2006 follow-up) (#2118)
#2006 made a bevel size change dearer on purpose (+10-75% on the GPU canvas,
2x for CPU text bevels) and asked for a bench of it under perf/.

`bevel_bench` renders a 12 MP document with one Chisel Hard inner bevel on
an anti-aliased ellipse (a smooth outline) or on a text paragraph (many thin
edges). Each case warms the caches one pixel below the size, then times
refreshes with the size growing by one pixel each, so the GPU canvas
rebuilds its distance fields every time (it keeps them while the size
shrinks) and the CPU compositor misses its map cache. Sizes 8, 50 and 250,
GPU and CPU, with `--scene`, `--size`, `--reps`, `--no-cpu` and `--json`.

Registered in perf/budgets.toml as P52-P59 with no budgets (track only,
like P45 and P46): GPU for ellipse and text at 8, 50 and 250, CPU at 8.
docs/scorecard.md is regenerated; the rows read "not run" until the next
baseline.

RTX 4080 SUPER, release, 9 reps, p50 ms, before #2006 (3bc49f5e) -> after:
ellipse GPU 88 -> 105 (8), 169 -> 199 (50), 305 -> 460 (250); text GPU
119 -> 140, 141 -> 252, 238 -> 557; CPU text at 8 402 -> 797. Runs vary by
about 20% on this machine.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 19:39: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
Sir StarchandClaude Opus 5.5 d8d2df4fba camera_raw_scope: serialise the GPU tests with GPU_LOCK (#1856)
Concurrent wgpu instances segfault in libvulkan with NVIDIA and lavapipe both installed; the three
GPU-harness tests in camera_raw_scope now hold a GPU_LOCK, like canvas_16f. Fixes #1855.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:51:40 -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 <[email protected]>
2026-10-09 11:51:36 -04:00