A shape layer's path is its vector mask in Photoshop (Properties > Masks),
with its own vector-mask density and feather. PhotoCraft's ShapeLayer had
neither, so soft shapes could not be made, and stroked soft shapes from PSDs
lost both on import.
- doc: ShapeLayer gains `density` (0..1, default 1) and `feather` (px,
default 0).
- vector: the shape renderer applies them to the fill as a soft vector mask
(fill shows beyond the path at 1 - density; the feather is the vector
mask's Gaussian), so the shape's own pixels, the GPU (which samples them)
and the stroke/fill split all follow. The stroke stays crisp. The feather
blur moves from compose::masks to vector::feather and both share it and the
feathered area, so a shape and an equal vector mask render the same pixels.
- compose: soft shapes' bounds reach their fill, their layer effects follow
the soft alpha, and the split cache key includes density and feather.
- engine: `layer.vectorMask.edit` on a shape layer sets density/feather in one
history step (path/enabled/linked/invert are refused there); density is now
validated (finite 0..100) instead of clamped. `layer.vectorMask.info`
reports a shape's values.
- ui: Properties shows the Vector Mask Density and Feather sliders for a
selected shape layer in both themes (live drag, one undo per gesture).
- .pcraft: manifest and convert carry both with serde defaults (older files
load at 100 % and 0 px).
- PSD: export writes them as the vector density/feather mask parameters;
import reads them into the shape (re-rendered, as the stored pixels lack the
fill beyond the path). Soft unstroked solid/gradient shapes now import as
shapes instead of fill layers with a vector mask; pattern-filled ones stay
fill layers.
Photoshop's 10 px feather on a 100 px black square, row 100, x = 30..72:
ours is within 1 level of every measured value (8, 16 and 32 bits);
density 50 % shows 127/128 beyond the path and 0 inside.
Scorecard: TOOL-214-12 done.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Tests: bad-layer-id checks use an id that can't exist (#3343)
Layer ids come from one process-wide counter (shared with document and
smart-object ids, and bumped by ensure_ids_above on file loads). A full
`cargo test` run passes ids like 9999, so a layer a test just created can
get the "bad" id, the command succeeds and the assertion fails at random
(seen on CI in type_extra_cmds::tests::vertical_orientation_renders_columns_inside_the_canvas).
Use u64::MAX, which the counter never reaches, for every "no such layer"
sentinel in the engine and ui-egui tests. Test-only change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Tests: use u64::MAX for bad-layer-id sentinels added on main since (#3343)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
A mask made from the selection takes the selection with it in Photoshop: after
Layer > Layer Mask > Reveal Selection (the Layers panel's mask button with a
selection) the marching ants are gone. PhotoCraft kept the selection, so the
usual next step, Invert (Cmd/Ctrl+I) on the new mask, inverted only the part
inside the old selection and left the whole mask black, instead of flipping
the mask. Hide Selection (the Alt-click variant) already dropped it.
Reveal Selection now clears the selection in the same history step as the
mask: one undo brings the selection back and removes the mask.
Tests: adjust_cmds::tests::reveal_selection_drops_the_selection_so_invert_flips_the_whole_mask
(8/16/32-bit). Tests that deselected right after Reveal Selection no longer
need to (Deselect is disabled without a selection); layer_mask_depth and the
automation agent tasks now expect the selection to be gone.
Co-authored-by: abc2cb <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(vector): add shape from selection with simplify tolerance (#214)
- Add tolerance and simplifyTolerance parameter (clamped 0.5..10.0 px) to select.convertToShape.
- Wire select.convertToShape in filter_dialog::has_dialog to prompt for tolerance.
- Add tests for knot count tolerance scaling and selection requirement.
* rustfmt
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]>
rounded_rect took the absolute width/height without moving the origin, so
shape.create {"kind":"rect","rect":[50,10,-20,30]} covered x 50..70 while an
ellipse or polygon with the same rect covered 30..50. Normalise (x, w) and
(y, h) for negative sizes instead. The Shape tool drag already normalises its
rect, so interactive drawing is unchanged.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
layer.vectorMask.edit stored `feather` as f.max(0.0) as f32, so 1e39 became
inf (hard edge, null in info, .pcraft save refused) and any huge value was
accepted. It now takes the same finite 0..=1000 px range and error as
layer.layerMask.edit, checked before the edit so a bad value leaves the mask
and history untouched. The params doc says so.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* fix(engine): use document depth for new pixel masks
* test(engine): check mask depth and selection coverage
* test(engine): preserve mask edits across native and PSD round trips
* Paths: Stroke Path with any brush-based tool and Simulate Pressure (#2978)
path.stroke now takes every brush-based tool of Photoshop's Stroke Path
dialog: besides the Brush, Pencil and Eraser it renders itself, the
Background Eraser, Clone and Pattern Stamp, Healing and Spot Healing
Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color
Replacement and Mixer Brush run their paint.* command along each
subpath, in one history step, with only path.stroke journalled.
simulatePressure resamples each subpath and ramps the pressure from 0 at
its start to full at the middle and back to 0 at its end, so a brush
whose size follows pen pressure tapers at both ends.
The Stroke Path dialog (Pen/Path Selection right-click menu, the Paths
panel row menu, Alt-click on the panel's Stroke button) asks for the
tool and Simulate Pressure, as in Photoshop. Scorecard TOOL-214-11 done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Automation: classify path.stroke's params as a document vector path (#2978)
The new Stroke Path params doc says the tools run "along the path", which
registry_filesystem_path_params_are_classified reads as a possible host
file path. path.stroke strokes a document path and forwards only to
paint.* commands that take no files, so it joins the vector-path list.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Transform: Distort, Perspective and Warp keep shape layers live (#3009)
Edit › Transform › Distort / Perspective and Warp on a shape layer let the
user edit the box or mesh and then refused at commit ("needs a rasterized
shape"). Photoshop maps the shape's path and keeps it a shape.
- photocraft-vector: new `map` module. `map_points` sends every anchor and
control point through a map (Distort / Perspective: exact for straight
segments, exact end tangents for curves). `map_path` follows a curved map
(Warp), halving each segment until the mapped control points follow the
mapped curve within a tolerance (at most 64 pieces a segment, bounded in
total); straight segments whose image is straight stay straight.
- engine: `transform_layer` and `warp_layer` map a shape's path instead of
refusing; a live rectangle/ellipse/polygon/line becomes a plain path. A
linked vector mask now follows Distort / Perspective / Warp too. A map that
sends a point to infinity is an error, not a corrupt path.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Transform Path: Distort, Perspective and Warp apply to paths (#3027)
path.transform accepted only an affine matrix, so the Free Transform
Path commit refused any box pulled out of a parallelogram ("Distort and
Perspective can't be applied to a path"), and Warp was not offered on a
path at all. path.transform now also takes a `quad` (with an optional
`rect` frame, default the path's control bounds) and a warp (`style`,
`mesh` or `warp`, as edit.transform.warp), validated (finite numbers,
magnitude at most 1e6, non-degenerate quad) and mapped with the
vector::map helpers from #3025. A parallelogram quad stays affine, so a
live shape stays live. The UI commit sends the quad or warp instead of
refusing, and Transform Path offers Warp; each is one undo step.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Layer › Combine Shapes › Unite / Subtract Front Shape / Intersect Shape
Areas / Exclude Overlapping Shapes only changed the operations between the
components of the active layer. With two single-shape layers selected (the
report's case) the active layer had one component, so nothing happened.
With two or more shape layers selected and no `layer`/`subpath` param, the
commands now merge them into the top-most layer in one history step: the
bottom layer's components keep their operations and every component of the
layers above takes the chosen one, in stacking order, then the other layers
are removed. Merge Shape Components merges them the same way (each layer
combined) and bakes the result. Selected non-shape layers are left alone,
locked layers refuse the merge, and one selected layer behaves as before.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* fix(shapes): preserve transformed outline on unchanged live geometry edit
A nonuniform scale stores an anisotropic outline but live rounded rectangles only store scalar corner radii. Avoid regenerating the path when all live values are unchanged, preserving transformed corners and PSD vector source. Cover no-op edits, real radius edits and undo at all depths.
* cargo fmt
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]>
* Drop f32 round-off coverage in the vector rasterizer (#2537)
Each row's cell accumulator is prefix-summed in f32. A left boundary adds
coverage and the right boundary subtracts it, but for curved or off-grid
edges the two don't cancel exactly, leaving ~1e-7 coverage in the pixel
just past the shape. Shape layers wrote the fill colour there (alpha
rounding to 0 at 8/16-bit, ~1e-7 at 32-bit), so content bounds and
shape.info reported curved shapes one pixel too wide.
Snap coverage below 1e-6 to 0 when writing a row. That is under half a
16-bit step, so 8- and 16-bit coverage values are unchanged; only the
residue pixels go away. Selections, vector masks and strokes share the
rasterizer and get the same fix.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Rasterizer: NaN coverage becomes 0, not full (#2537)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Fill Path painted the whole work path even when a selection excluded
part of it. The path's coverage (after anti-aliasing and feather) is now
multiplied by the selection mask, so rectangular, elliptical and
feathered selections all limit the fill. With no selection the whole
path fills, as before.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Layer > Rasterize > Vector Mask multiplied the hard vector mask into the pixel mask, dropping
the vector mask's feather (and the pixel mask's feather or density). When any of those shape
the edge, the pixel mask now becomes exactly the combined mask the compositor showed, with
feather and density baked in. Tested at 8, 16 and 32 bits against the composite. Fixes#992.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Add blend modes to Gradient tool
* Establish context menu parity contract and first workflows
* Align Select menu actions and style selection context rows
* Add Pen canvas Make Selection context action
* Complete Pen path context menu and symmetry actions
* Select menu: no extra top-level Feather (keep Select > Modify > Feather only, for 1:1 menu parity)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Regenerate docs/scorecard.md and docs/parity.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Regenerate docs/scorecard.md and docs/parity.md
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]>
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 <[email protected]>