* chore: resolve SonarCloud maintainability issues
* fix: format imports and fix effect_entry argument type
* fix: restore test closure and wildcard import that broke compilation
* refactor: split composite_to_surface to reduce cognitive complexity
* fix(curves): 32-bit Curves continue past 0..1 like Photoshop
In a 32-bit RGB document an edited Curves clipped every sample to the
curve's table, so HDR values above 1 (and negative ones) were flattened.
Photoshop extends the curve beyond its end points along its end segments:
the straight curve [[0,0],[255,128]] maps -0.5, 0.5 and 2 to
-0.250980407, 0.250980407 and 1.003921628 (measured in Photoshop 27.11 on
the issue). The CPU compositor, Image > Adjustments and the GPU kernel
now do the same, with the end slopes measured over 16 table steps.
8/16-bit documents and unedited curves are unchanged.
Fixes#7157
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(curves): extend 32-bit curves in RGB documents only
The curve's space is not the document's: a grayscale, CMYK or Lab
32-bit document with an RGB-space curve keeps the table's ends. The
document mode now reaches the decision on the CPU (apply_in, the
compositor) and the GPU (adjustment_program).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* security(compose): bound dense working sets and repeated group evaluation
Smart-filter mixing and stroke fitting work tile by tile instead of over the
union of sparse surfaces; render_layer admits its rect like render; hidden
layers no longer disable the proxy and uniform-area shortcuts; Blend If
groups nested deeper than two are judged by their result, so nesting costs
polynomial instead of exponential work; effect builds past an absolute byte
cap are skipped.
* CodeRabbit #8949: exact tile skips in filter_mask, allocated-tile scan in fit_stroke, contrasting backdrop in the nested-group test
- filter_mask mix/unmix: bare tiles (no tiles on either side) skip for
non-alpha formats too when the two surfaces' default pixels are equal;
differing defaults still get processed, so the clone's default pixel is
never left wrong under a non-unit mask
- shape_split fit_stroke: scan the union of the three surfaces' allocated
tiles (exact skip when all three defaults are transparent) instead of
every tile of a 300k-canvas document; non-transparent defaults fall
back to the full scan
- tests: the nested-groups assertion now uses a black backdrop so the
asserted white can only come from rendering through the group chain
fixes CodeRabbit review comments (major/minor) on filter_mask.rs:27,29,
shape_split.rs:126, tests.rs:2354
* compose: skip a BTreeSet in fit_stroke — a sorted Vec compiles under the wasm toolchain (E0599 on ::drain there)
Main merged #5940, #6472, #7838 and #7922 directly. Kept from the batch:
the snapshot/History-follow identity at the history limit (#5940,
c7ef3676a; main still had the 3-tuple HistorySpot) and the strokeless
shape at a 0 px width step (#6472, 962539607; main still added the
stroke). Main already carries the other two fix-ups (34c9d0ea4, dff8bfd0e).
Overlaps with main resolved:
- #5842 vs main's #5568: one path, apply_color, tool-gated as #5842
has it, with #5568's float fill, kept fill opacity and lock skip;
shape_fill_ui's duplicate foreground_changed/gesture dropped; #5568's
tests run with a shape tool.
- #6230 vs main's #5180: one Type tool path click (path_target), no
second type.attachToPath; vertical type does not start type on a path,
as main has it; main's path_at/path_cursor and canvas arm dropped.
- #5452 moved the shape swatch to shape_paint_ui.rs: main's hex field
(#6201) ported there.
- type_extra_cmds::with_text takes main's dpi form, pub(crate) for
type_area_cmds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts, both sides kept: main's .act swatches next to the batch's .asl
styles (Open filters, preset extensions), main's Render smart filters next
to the batch's Distort ones (53 mapped), parity doc status lines.
docs/scorecard.md regenerated.
Follow-ups the merge needed: the Distort smart filter readers pass their
enum type to main's stricter enum_name (the type their writers use), and
the CMYK effect tests set main's new StrokeFx overprint and Bevel
gloss_anti_alias.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fx_region_bytes counted the shape plus one map per enabled effect. Strokes
also build an inside distance and one or two outside fields, and overlays
and strokes build no map of their own, so a stroke-heavy document could
stay under the estimate while thrashing, and an overlay-only one could be
clipped though it fits. effects::map_count, next to build_maps_prepared,
counts what it builds; the test checks it equals FxMaps::bytes per layer.
A render needs every layer's effect maps. On a large document whose
ordinary layers' whole-region maps take more than the effect cache budget
together, building one evicted the next one needed, so every render, of
any size, built them all again: 1.8-4.4 s per render on a 9000x6000
document with three full drop-shadowed layers. When they don't fit, local
maps are now built over the render rect padded by twice the effects'
reach, as a shapeless layer's already are (#1909), which gives the same
pixels there.
- Replace whole-region Vec<f32> read_region/write_region in CutParts::new
with tile-parallel cut_piece + try_fill_surface/try_clear_surface,
sharing interior Arc<Tile> allocations in O(1) and activating the
solid_tile hole-fill fast path on Background and mask targets.
- Cache piece_bounds on CutParts so CutParts::place avoids scanning tile
content bounds on every drag frame.
- Parallelize composite_over across top tiles with Rayon and share fully
opaque top tiles by Arc<Tile> clone in O(1), preserving COW tile
identity for GPU canvas uploads.
- Parallelize Surface::translated across target tiles with Rayon and
prune cleared tiles in-place inside edit_tiles.
- Skip zero-offset floating preview generation on pointer-down in
move_ui::floating_doc.
- Exclude SnapKind::Selection when moving selected pixels with the Move
tool so small initial drags do not snap back to (0, 0).
- Keep navigator_plain active during Move/Brush/Patch drags on a GPU
canvas so navigator_gpu is not evicted into a synchronous full-document
CPU thumbnail composite on the first drag frame.
- Parallelize row normalization in compose::render_reduced_in_bands and
add a direct read_rgba_into fast path for unmasked Normal layers over
an empty backdrop.
Look in from each edge through strips that double in thickness, so a subtracted
or intersected subpath's empty margin costs a few renders instead of one per row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A soft shape's transform frame, snap rectangle and Properties X/Y/W/H came from
the path plus the full stroke width on every side, so a centred or inside stroke
grew the frame when Density or Feather was set. The frame is now the pixels the
same shape renders at full Density without Feather (edge strips only, clipped to
the canvas), identical to the hard shape's for every stroke alignment.
feather_matches_photoshops_ramp now enforces the 1-level claim the scorecard
makes (measured max deviation 1 at 8, 16 and 32 bit).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Baseline x86-64 has no SSE4.1 roundss, so f32::floor is a software routine there, called once per sample of the backdrop under every adjustment layer of an 8- or 16-bit document. branchless_floor rounds through 2^23 and steps down where that went up: bit-identical to floor for every f32 (a new test sweeps every 4099th bit pattern and the edges around whole numbers and 2^23), and with no branch the loop vectorizes.
An adjustment layer's result was blended back into the backdrop pixel by pixel through blend_rgb, which looks up the HDR flag and dispatches on the mode, three channel calls deep, for every pixel. Normal (nearly every adjustment layer) gives the source exactly, so blend_back skips the dispatch for it, and walks the backdrop, the result and the coverage in step instead of indexing by coordinates. Bit-identical.
Reuse tile scratch and tone tables across rendering work. Skip fully masked
adjustments and identity float curves. Previous-path tests preserve output
across sample depths, masks and clipping.
A Gradient Overlay (and gradient strokes, linked patterns, linked bevel
textures) is laid out over the layer's bounds. Painting grows those
bounds, but the stroke commands reported only the stroke as damage, and
the canvas's committed-stroke path repainted only what the live stroke
had not shown. The canvas therefore kept the old gradient around earlier
strokes: one gradient per stroke instead of one over the whole layer.
Session dispatch now grows a command's reported damage by
photocraft_compose::relayout_damage(before, after): the whole composite
of any visible layer whose effects span its frame, when that frame moved.
The canvas adds the same area to a committed live stroke. Strokes inside
the existing bounds and frame-independent effects keep tight damage.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`bake` rendered each layer through `render_layer`, which has no document context (default Global
Light, no patterns, the render rect as canvas). It now uses the new doc-aware `render_layer_in`.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A pass-through group that is the base of a clipping group renders isolated while it holds a
visible adjustment (`clip_base_adjusts`), so showing, hiding, adding or removing a clipped
adjustment inside it can switch the group's other children (a Multiply layer beside the base)
between isolated and pass-through rendering. `change_bounds_in` now takes the list holding the
parent group and reports that group's change bounds in this case; `layers_damage`, `step_damage`
and `one_sided_bounds` pass it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Parallel bands each collected their finished tiles until the band's last
one was done, so with work stealing many bands could hold most of their
tiles at once, close to a second copy of the output. Each tile is now put
into its band behind a lock as soon as it is rendered; only the tiles in
flight are held, as before the bands ran in parallel.
The compositor draws a clipped adjustment that has no unclipped layer
beneath it as an ordinary adjustment, but change_bounds counted it as
contained in a pass-through group. Hiding the layer clipped to such a
group (which ends its isolation) or the group itself then recomposited
only the group's pixels and left the rest of the canvas stale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port the clipped-adjustment coverage path from #4711 and verify depth, alpha, masks, and document-coordinate GPU parity.
Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
A pixel layer with nothing clipped to it is read from its tiles a row at a
time and blended straight onto the backdrop, instead of being copied into a
tile-sized buffer first (a megabyte per layer per 256² render tile). Normal
RGB rows go through a branch-free kernel over blocks of 8 pixels that the
compiler vectorizes; few-band renders also run their bands in parallel.
Bit for bit the same: the row kernel is tested against the scalar loop (NaN,
infinities, subnormals, signed zeros), the row path against the tile copy at
8/16/32-bit with every kind of mode, fill and mask, and the whole GPU parity
suite's CPU references (1620 renders) are unchanged.
P60 on the CPU compositor: 627 -> 530 ms (the render without effects
300 -> 199 ms).
`photocraft_compose::change_bounds` returned "anywhere" for every
adjustment layer before looking at `clipped`, although a clipped
adjustment only changes its clip base's pixels. Hiding or showing one
(or undoing that) therefore recomposited the whole canvas, which on a
heavy document fails the GPU memory fit check and falls back to a full
CPU redraw taking seconds to minutes.
`change_bounds` sees one layer and cannot find the base, so a new
`change_bounds_in(layers, k, canvas)` looks at the sibling list: a
clipped adjustment's change bounds are its base's change bounds. It
stays `None` (anywhere) when no unclipped base lies beneath it (the
compositor then draws it as an ordinary adjustment), when the base is
an adjustment (layers clipped to it composite atop the adjusted
backdrop), and, through `change_bounds`, when the base is a
pass-through group holding an adjustment (visible clipped layers make
that group isolated, so they decide whether its adjustment reaches the
layers beneath) or draws outside its bounds. The engine's
`layers_damage`, `step_damage` and `one_sided_bounds` use it.
Tests: a compose unit test that the clipped adjustment's bounds equal
the base's, that every pixel the toggle changes lies inside them, and
the cases that stay unbounded; an engine test that hide, undo, redo,
show and `layer.setProps` visible report the base's rect (top level and
inside a group), and that recompositing only that rect over the
previous canvas equals a full recomposite.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A shape below 100 % Density renders over the whole canvas, and a Feather
grows its pixels, so Free Transform, the Move tool's transform controls,
snapping and Properties > Transform framed the canvas or the blur. As in
Photoshop the frame now follows the path and stroke whatever the mask
Density and Feather: photocraft_compose::hard_shape_bounds (shape_bounds
without the soft reach) feeds whole_layer_bounds and the Properties
Transform fields. Rendering keeps the soft bounds; other layers and hard
shapes are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Keep effect-identity memo lookup, pure hashing and insertion atomic across concurrent render callers. Preserve existing identities and add a contention regression with eight synchronized callers.