* fix(compose): a wide stroke effect keeps a round outer edge (#3646)
A stroke's outside distance came from the 5x5 chamfer, whose level sets are polygons: at a
245 px outside stroke the boundary wanders ~10 px into visible flat facets. Use the exact
Euclidean distance (edt_nearest, already used for glows and chiselled bevels) for the outer
edge; the inside edge and spread/choke keep the chamfer, whose sub-pixel edge seeding is
fitted to Photoshop at small offsets. A point source dilated by 60 px now stays within 0.5 px
of a circle (was 2.5 px).
Tests: a_wide_outside_stroke_is_round_not_a_polygon; stroke_edges_use_the_exact_euclidean_distance
(a 3 px outside stroke's (1,3) corner coverage is 0.838, the exact distance; it was 0.764).
Full photocraft-compose suite: 206 pass; fmt and clippy clean.
* fix(gpu): a wide stroke keeps its exact outer edge on the GPU (#3646)
1080008a fixed the CPU compositor's stroke outer edge to use the exact
Euclidean distance instead of the 5x5 chamfer, whose level sets are
polygons and facet a wide stroke. The GPU compositor builds the outer
stroke field from FieldKind::StrokeOutside, which still maps to the
chamfer, and the canvas is drawn on the GPU, so the fix did not reach the
app.
Add FieldKind::StrokeOutsideExact (dist_outside_by Euclidean) used only by
the stroke. The spread of drop shadows and softer outer glows keeps the
chamfer on purpose (fitted to Photoshop, #224), so StrokeOutside keeps its
meaning. StrokeOutsideVector is exact too, matching the CPU vector outline
path.
Tests: crates/gpu/src/fx.rs a_raster_stroke_uses_the_exact_outer_field
(stroke uses the exact field, shadow spread keeps the chamfer), and the
banded/whole field test covers the new kind.
* fix(stroke): a stroke keeps the chamfer narrow and the exact edge wide (#3646)
The exact Euclidean outer edge (#3646) fixed a wide stroke's faceting but
regressed Photoshop's own composites against the PSD flatten oracle: the
fitted 5 × 5 chamfer matches Photoshop at small offsets, and only at a large
outside width do its octagon facets show. Pick the metric by the stroke's
outside width instead: below `STROKE_CHAMFER_MAX` (20 px) keep the chamfer and
its sub-pixel edge seeding, at or above it use the exact Euclidean transform.
The measured chamfer deviation from a point source is 0.027 · r px — 0.48 px at
r = 18, the widest outside stroke any Photoshop oracle matches with the chamfer,
and 1.6 px at r = 60, where the boundary visibly facets. Half a pixel is reached
at r ≈ 19, so the switch sits at 20. Inside stroke edges keep the chamfer.
The CPU and GPU compositors pick the same field by the same width
(`stroke_outside_exact`); `FieldKind` gains `StrokeOutsideVectorChamfer` for a
narrow shape-layer outline, while the shadow / glow spread keeps its chamfer.
io corpus: 140 → 148 (floor 146) and psd-tools 231 → 237 (floor 236), both back
to main's counts with no file newly differing; a 60 px outside stroke stays
round (`a_wide_outside_stroke_is_round_not_a_polygon`).
Carry only the linked-source records referenced by copied layers through Copy/Paste and save/reopen. Preserve raw records, avoid duplicate payloads on same-document paste, and reject ambiguous or incomplete selected identities before replacing the clipboard.
Co-authored-by: skylar taylor-barrick <[email protected]>
Flatten All Layer Effects baked the layer (including its masks) into the pixels and then cleared mask and vector_mask, destroying unrelated mask pixels and their enabled/disabled state permanently after save. Bake the effect visuals with the masks left off the render and keep the original mask and vector_mask objects, so each mask still applies once to the baked raster and the command stays a visual no-op.
Tests: cargo test -p photocraft-engine --lib file_cmds; cargo test -p photocraft-engine --lib; cargo clippy -p photocraft-engine --all-targets (with the two pre-existing lints allowed); cargo test -p photocraft-engine --test panic_hunt -- --ignored
A `history.*` namespace exists (history.deleteState, history.newDocument) but not history.undo, so a client or agent guessing `history.undo` gets a hard `unknown command`. Register history.undo and history.redo as aliases of edit.undo and edit.redo, with no menu entry and no shortcut; the Edit menu items keep Cmd+Z / Cmd+Shift+Z.
Tests: cargo test -p photocraft-engine --lib history_cmds; cargo test -p photocraft-engine --test panic_hunt -- --ignored; cargo xtask parity.
Photoshop shows a preview of the filtered result inside its filter dialogs; ours only had the parameter widgets, with the live result on the canvas behind the dialog. The generic schema dialog now draws a preview window from the same downsampled proxy the canvas already computes, with - / + zoom steps and a percentage readout, drag to pan, a click to centre on a document point, and press-and-hold to compare with the unfiltered image. It sits under the parameters (reserving its height so a small window still fits OK/Cancel); the Preview checkbox still toggles the on-canvas result, unchanged.
Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib (only the pre-existing Czech cs.tsv duplicate-key test fails); cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm; a snapshot of the Emboss dialog shows the window and its zoom controls.
Plain text fields (Preferences search and hex, the Color Picker hex, and the brush search fields) each drew egui's default TextEdit frame, so they differed in padding and border and showed egui's white focus ring (its selection.stroke). Add widgets::text_field, which draws the same box as a value_field (field fill, field_border, radius_sm) with frame NONE, a consistent inner padding, no focus outline, and a scoped, non-blue text-selection colour (grey in the monochrome Pro themes, the theme accent elsewhere). Migrate those fields to it; numeric fields already use value_field.
Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib; cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm
* fix(engine): a PSD type layer names the installed font, not an invented one (#3556)
A type layer created with a font by family name stored no PostScript name, so PSD export
invented one ("<Family>-Regular", spaces removed). Photoshop looks type up by PostScript name,
so it showed the font as missing and substituted Myriad Pro as soon as the text was edited.
Resolve the family's installed face (matching weight/italic) from the font database when the
family, style, weight or italic is set, and store its real PostScript name; an explicit
postscriptName still wins. A family PhotoCraft does not have keeps no name, so the export falls
back as before.
Test: a_family_font_gets_the_installed_postscript_name; the existing named-face test now expects
the resolved name.
* Type tool test: a family change names the installed face (#3556)
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]>
Swatch groups and the shared preset-browser folders could only be clicked, so there was no way to put them in the order the user wants. Dragging a folder header now mirrors the dock's per-group tab reorder (#2272): an accent insertion line shows where it will land, and the order is stored by name in PresetUi (UI state, groupOrder) so it saves and reloads with the rest of the state, reading as leniently as tabOrder. Because the folders share one browser, Gradients, Patterns, Styles and Shapes get it too.
Tests: unit tests for the reorder and lenient/persisted order, plus an egui_kittest drag of a group header in the Swatches panel.
A translated label beside a fixed-width control could run over its neighbours: the Layers
Lock/Fill row guarded only the redundant "Lock:" caption, so "Fill:" ("Заливка:" in Russian,
longer still as pt-br "Preenchimento:" / fr "Remplissage :") overlapped the lock icons.
Rather than a per-row fit check, add the one shared primitive for fixed chrome:
`widgets::elided_label(ui, text, max_w)` lays the text out no wider than its room, cuts it with
'…' and puts the full string on hover (built on the existing `tab_strip::elided`). The
Opacity/Fill scrub labels now elide to the room beside their field, so any translation shrinks
instead of overlapping, in every locale.
Documented the rule in docs/ui-design.md: wrap by default; beside a fixed control, elide; a
redundant caption may drop on a narrow dock (the lock icons carry the meaning).
Test: widgets::tests::elided_label_never_exceeds_its_room. Full photocraft-ui-egui suite: 2077
pass; fmt and clippy clean.
PSD export always wrote a document-level Txt2 block, and for a fresh document its
document-resources dictionary (/0) was the empty `<< >>` Photoshop never writes. Photopea's
reader fails on it ("Cannot read properties of undefined (reading '_Resources')"), so a PSD
saved with a new type layer stopped opening there.
Write the block only when it carries something: the file already had one (round-trip its pen
positions and extras), or a layer uses the Optical auto-kern mode - the one mode EngineData's
AutoKerning cannot distinguish (#1348). A plain Metrics document now writes no block, as 0.3.0
did.
Tests: text needs_txt2_only_for_optical; io fresh_metrics_text_writes_no_txt2_block.
The midpoint diamond's hit-test and drag were untested, so a regression there would go
unnoticed. Add a repro that drags the diamond and asserts the midpoint follows (and no stop
is added). The path works on current main; this is coverage, not a behaviour change.
While a crop is pending, Edit > Undo/Redo now steps the *box*, not the document, as
Photoshop does. The frame's history lives on CropState; crop_ui::is_enabled/intercept route
the commands for every caller (menu, shortcut, control channel, MCP), and committing or
cancelling a crop clears it.
The ratio-field input is PR #3175. The handle-drag jitter is left as-is on main (whose
live crop-handle snapping, snap_ui::live_crop_handle, deliberately redesigns that path).
Test: edit_undo_steps_the_crop_box_not_the_document. cargo test -p photocraft-ui-egui --lib crop: 101 pass.