A higher Output resolution passed the resample budget (document bytes only)
but then allocated ~40 B/px of f32 composite, gray and output buffers at the
new size. Budget the whole working set before resampling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A turned crop rotated the whole canvas, then cropped to the frame. With
Delete Cropped Pixels on, every layer, mask and channel is now trimmed to
the frame's source (plus the interpolation kernel's reach) before the
turn, so the rotation costs the frame's size: a 512x442 frame turned 3°
on a 3859x2594 two-layer document goes from 541 ms to 42 ms.
Rotate Canvas also fills the Background with shared tiles instead of a
canvas-sized float buffer.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Crop tool's W x H x Resolution mode resampled with the chosen filter
(Bicubic by default) even on an Indexed Color document, so the result held
blends outside the colour table. Like Image Size (#6019), it now falls back
to Nearest Neighbor there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The target size was read with as_u64 only, so 100.0 (json.dumps of a
float, many agent clients) was refused as not a whole number, while the
crop rectangle and image.imageSize accept it. Fractional, negative,
zero and out-of-range values are still refused.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A fill layer from a PSD keeps its stored pixels as a fill cache that covers
only the old canvas. Canvas Size, an extending Crop and Reveal All translated
that cache but kept it, so the added area of the fill layer stayed
transparent. Drop the fill caches whenever the canvas grows, so the fill
re-renders from its model over the whole canvas, as Photoshop does (Free
Transform and Perspective Crop already drop it).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`compose::flatten` returns an empty buffer for canvases over MAX_RENDER_PIXELS.
Trim (Top Left) indexed `px[0]` of it and Duplicate › Merged Layers Only fed it
to `write_region`'s length assert: both panicked; Trim's other modes reported
"nothing to trim to". Both now return a "too large to flatten" error first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
image.imageSize set the new resolution and then resampled, which scales
the type transform by the pixel factor and re-renders at the new ppi
(text px = pt x dpi / 72): doubling 72 -> 144 ppi made text 4x as tall.
With Resample off the pixels stayed but the next re-render doubled them.
canvas_geom::set_resolution now changes the resolution keeping type the
same size in pixels, as Photoshop does: text space grows by new / old, so
the box and path scale with it and the layer transform undoes that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Image > Mode > Bitmap now opens a dialog laid out like Photoshop's:
Resolution (Input, read-only; Output in pixels/inch or pixels/centimeter),
Method (50% Threshold, Pattern Dither, Diffusion Dither, Halftone Screen,
Custom Pattern) with a pattern picker enabled for Custom Pattern, and the
Halftone Screen settings (Frequency, Angle, Shape) shown only for that method.
The image.mode.bitmap command gains `resolution` (resamples to the Output
resolution keeping the print size, then sets it) and the `customPattern`
method (threshold map ranked from the pattern's lightness, tiled from the
top-left). The halftone screen is laid out at the output resolution. The
live preview keeps the canvas size.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An Indexed Color document holds only its colour table's colours, and a
save quantises to them. Interpolating methods showed blended colours on
screen that the saved file then lost. image.imageSize now resamples an
Indexed document with Nearest Neighbor whatever method it is given, and
the dialog offers only Automatic and Nearest Neighbor for one.
* fix(doc): variable definitions follow layer id remapping on copy and load
A duplicated document (image.duplicate, history.newDocument) or a .pcraft
loaded with remapped ids assigned fresh layer ids but cloned VariableDef.layer
unchanged, so datasets silently drove no layer (or, after remap, a colliding
one). Remap variable targets through the same id map as layer comps and
slices; a dangling target keeps a non-colliding placeholder. A merged-only
duplicate drops the whole variable binding set, matching how it drops comps.
Closes#4969
* style: cargo fmt duplicate_variables test
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
* Crop tool: Content-Aware fill of the area a rotated or extended crop uncovers (#3823)
image.crop gains contentAware (bool, default false). After a turned crop or one that extends
past the canvas, the area the original image doesn't cover is filled from its surroundings on
the Background (else the active pixel layer), in the crop's one undo step. Each connected piece
(corner, edge strip) gets its own Content-Aware sampling window and samples only covered pixels;
the patch search is Edit > Fill > Content-Aware's (fill_cmds::content_aware_native, shared). A
crop that uncovers nothing is unchanged; one with nothing to sample from fails and rolls back.
Crop options bar: Fill: Background (Default) / Content-Aware Fill (ToolOptions
crop_content_aware, ui.set cropFill). Generative Expand is not offered.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Crop: adapt to Document::background after main removed is_background
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Crop: translate Background (Default) for Hungarian and Turkish
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Fix the merge with main: UI_SET_FIELDS length, Serbian Background (Default)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Merge main: UI_SET_FIELDS counts both new fields
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* feat(color): Dot Gain 20%-compatible default working gray, as in Photoshop
Photoshop's default working gray is Dot Gain 20%, and its RGB -> Grayscale
measured in Photoshop 27.11 (default Color Settings) is D50 luminance
encoded with gamma 1.732: sRGB 64 / 128 / 192 grey and pure red become
46 / 105 / 177 / 107. PhotoCraft defaulted to sGray, which kept the sRGB
value (64 / 128 / 192 / 130), up to 24 levels off.
Adds the built-in "Dot Gain 20%-compatible (gamma 1.732, Photocraft)"
gray profile (id dot-gain-20-compat; our own fitted curve, not Adobe's
profile, the same gamma PhotoCraft already uses for gray adjustments) and
makes it the default working gray and the fallback for untagged gray
documents. sGray and Gray Gamma 2.2 stay selectable.
This changes a default: untagged gray documents now convert and display
as Photoshop shows them (midtones lighter than under sGray); documents with
an embedded profile are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Status bar test: the default working gray is now Dot Gain 20%-compatible
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* .atn test: #202020 in the Dot Gain 20%-compatible working gray
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>
Third-party action sets that load since 3204231 now play. Four TrueGrit
Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine,
Transparency) go from 223 to 3 unmapped steps of 533 and play end to end.
- .atn import, 108 -> 112 events: Select and Mask (refineSelectionEdge),
Filter Gallery Spatter (GEfc), Ripple, Crystallize; targeting the layer
mask or composite channel, loading the targeted channel, Step
Backward/Forward, removeFromSelection (it silently imported as a plain
select), Pattern Fill layers (by name), nudges in points, Layer From
Background, Grayscale/Bitmap mode objects, Image Size by resolution.
- Engine: layer.translate units pt; layer.select mode remove;
select.loadSelection channel targeted; image.imageSize keepPrintSize;
layer.new.layerFromBackground ifPresent/select; pattern.import data.
New from Clipboard keeps the copied document's resolution (was 72 ppi).
- Filters: Spatter's Smoothness smooths the displacement (torn but
attached edges, as Photoshop's); Crystallize fills a cell from its point
instead of the average, so masks keep hard edges.
- UI: .pat opens/drops into the Patterns panel (no UI import existed).
- Docs: gaps G11, file-format and target-app parity.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
- image.imageSize takes percent (both sides; an explicit side wins).
- actions.conditional {if, then, else}: Photoshop's Insert Conditional as a step, 33 conditions
on the document (shape, mode, depth, selection, layers, alpha channels, unsaved) and the
active layer (kind, locks, visibility, masks, effects); a failing branch stops the caller.
- .atn events: placeEvent → file.placeEmbedded/placeLinked (path, scale), open → file.openAs,
imageSize in percent, fill from a point → paint.bucket (pixels), conditional. 105 → 108.
- Panel labels conditional steps as 'If cond → then / else'.
PhotoCraft treats 32-bit documents as linear light (Transfer::for_document,
Exposure, Photo Filter, PSD import), but Image > Mode > 32 Bits/Channel only
rescaled the samples, so a converted 8-bit grey 128 stayed 0.502. Photoshop
stores 0.2159 (measured in Photoshop 27.11: 32-bit float TIFF of an sRGB
chart, every sample = the sRGB-to-linear value). The conversion now decodes
colour samples through the document's tone curve after widening to 32 bits,
and encodes them before narrowing (so no linear value is quantised to 8 or
16 bits). Alpha, masks and alpha channels keep their values. RGB and
Grayscale only (the 32-bit modes).
With it, Photoshop's 32-bit results match exactly for the conversion itself,
Levels, Black & White (default), Brightness/Contrast and Colorize (were 40-80
levels off in sRGB terms).
Adds Surface::map_color (colour samples only, including the default pixel)
and makes Transfer::decode/encode public.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is
transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景",
"Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the
engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops,
straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized
file's deep knockout revealed transparency instead of the Background.
- `Document::background` / `background_mut` / `is_background` and `Layer::has_background_traits`
in the doc model replace every name check; `extra_cmds::background_to_layer` is the one
"Background → Layer 0" conversion.
- PSD import marks the bottom record position-locked when it is transparency-protected and has
no transparency channel, with or without an `lspf` block. Affinity import and HDR Toning now
lock the Background's position like every other place that creates one.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Free Transform: layer styles render with the transformed layer (#3653), work in progress
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653)
The live preview drew the moving pixels as a bare texture on a mesh, so Stroke,
Drop Shadow and every other effect vanished while the box was open (and for
type, shapes, groups and smart objects the style was baked into the texture,
cut off at the layer's bounds and stretched with the box).
A styled layer is now composited for the preview: the moving texels are
resampled through the box and the style is laid over them by the CPU
compositor's composite_with_effects, the code that draws the layer once the
transform is applied. A stroke keeps its width when the box is enlarged and a
shadow keeps its direction when it is turned, as after the commit.
The composite is made at screen resolution (never finer than the document),
with the style's sizes scaled to match, within a pixel budget from a measured
cost per effect: about 30 ms per rebuild while dragging and 120 ms at rest.
It is rebuilt only when the box, the zoom or the layer changes.
Still plain: the selected part of a styled layer, and Warp mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Merge fix: Warp on type reads the texels as the session did (bare when the box draws the style)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Format the type warp texel read
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Free Transform: Content-Aware Scale's preview has no styled composite
Main added a TransformPreview initializer for Content-Aware Scale (#3103) after this
branch started; give it the new field so the merge builds.
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>
anchor_factors resolved the x and y axes independently and with different matching
rules (x looked for a capitalised "Left"/"Right" or the exact word, y for a lowercase
"top"/"bottom" prefix). A misspelling matched one rule and missed the other, so
"topleft" anchored top-centre and "TopLeft" left-centre -- anchors the command does not
offer -- with no error.
Match the anchor exactly against the nine documented values via one ANCHORS table;
anything else is a BadParams error naming `anchor` and listing them, as #507 settled
for other command params. The nine valid values keep the exact factors they had, and an
omitted anchor still means center.
Test covers the nine offsets, the omitted default, and the misspellings from the issue
("topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE").
Fixes#2946.
Co-authored-by: photocraft-agent UNCONFIGURED <unconfigured@invalid.local>
* Tools: Artboard tool (#2858)
Photoshop's Artboard tool in the Move flyout (V, ⇧V cycles Move ↔ Artboard):
dragging on an empty spot draws a new board (`layer.new.artboard`), clicking a
board selects it, dragging it moves it with its contents, and the selected
board's edge and corner handles resize it (`layer.artboard.set`,
moveContents: false). Each gesture is one undo step. Options bar: Size preset,
W/H, Make Portrait, Make Landscape, Add New Artboard. Original toolbar glyph
(assets/icons/artboard.svg, ATTRIBUTION.md row).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Artboard tool: '+' buttons add a board beside the selected one; ⇧ keeps proportions (#3033)
With the Artboard tool, the selected board shows a "+" button beside each
side. A click adds an empty board of the same size (background and preset
copied) on that side, the 100 px artboard gap away and past any board in
the way; ⌥-click adds a copy with its contents instead. A board left of or
above the canvas origin shifts the whole document right/down and grows the
canvas, so it stays visible. Both go through the new engine command
`layer.artboard.addBeside` {layer?, side, duplicate} (one undo step).
⇧ while dragging a corner handle keeps the board's aspect ratio: the
opposite corner stays and the board scales by the larger axis factor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Color Sampler tool (#1046)
Add the Color Sampler to the Eyedropper (I) flyout: click to pin numbered
sample points to canvas pixels, drag to move, Alt-click (or drag off-canvas)
to remove, Clear All in the options bar, at most 10 per document. Each point
reads one composite pixel (RGB 0-255 and naive device CMYK, same as the
pointer readout) and the Info panel lists them under their number with the
position in the ruler unit; the readouts refresh when the document changes.
Points are per-document engine view state (DocState.color_samplers): they
belong to their document, are discarded on close, and are not saved to
.pcraft or PSD. They are driven by commands shared by the UI, CLI, control
channel and MCP (view.colorSamplers.add/.move/.delete/.clear/.list); the
mutations change no pixels and add no History step.
Points follow the canvas geometry (crop, Canvas Size, Image Size, Image
rotation/flip, rotated crop) through a pending Session.sampler_map that
Session::edit applies before culling the points that land outside the new
canvas; the count removed is reported in the status bar.
- engine: new sampler_cmds.rs (commands + shared readout helpers) and the
sampler_map wiring in lib.rs, image_cmds.rs, mode_cmds.rs, commands.rs,
analysis_cmds.rs (compound rolls points back on error).
- ui-egui: Tool::ColorSampler + original sampler.svg icon, toolbar slot,
Info panel list, options bar, pointer handling, overlay markers, cursor.
- docs/scorecard.md regenerated (TOOL-1649-1 no longer says the tool is
missing). No menu item, so parity is unchanged (627/627).
* Color Sampler follow-up: tool count, README, i18n, roadmap
The full ui-egui suite (not just the filtered tests) surfaced these gaps:
- state.rs: Tool::ALL.len() assertion 52 -> 53.
- README.md: "52 tools" -> "53 tools" and add Color Sampler to the list,
so readme_tool_list_matches_tool_all passes.
- i18n: the options-bar hint, "{n} of {max} sample points" and
"No document open" need entries in every catalog that claims complete
menus (every_tl_literal_is_translated); added to all 16 (coverage
1584/1584). The translations were drafted alongside the tool.
- docs/roadmap.md: drop Color Sampler from the missing-tools list and note
it landed.
* Color Sampler: fit main's new tools and Image Size resample type
Reset Tool covers ColorSampler, the test ViewXform sets aspect, and
Image Size checks Method::None instead of an Option.
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>
* Image Size: Photoshop's resampling, measured
Photoshop 25.4's Image Size was measured as a black box (synthetic 16-bit
images in, exact RAW out); every method but Preserve Details' enlargement is
linear, so each was taken apart from its impulse responses.
- Bicubic is the Keys cubic at a = -0.75, not Catmull-Rom.
- Bicubic Smoother (stretched x1.15, a = -0.625) and Bicubic Sharper
(x1.05, a = -1), with a growing with the reduction, and Bicubic Automatic.
- Reducing widens the filter by 1/s; below 1/4 the image is first averaged
down to four times the target. Bilinear is never widened and averages
whole pixels below 1/2. Positions are rounded to 1/128 pixel and stop at
the edge pixel's centre.
- Two passes, the smaller scale first, clipped and stored between them; the
bicubic filter is designed for the height's scale and serves both axes.
Bicubic Automatic splits a mixed resize into Sharper, then Smoother.
- Image Size and Fit Image take automatic, bicubicSmoother, bicubicSharper
and bicubicAutomatic; the dialog lists Photoshop's methods and opens on
the interpolation preference.
On 225 probes the mean error falls from 240-550 to under 0.5 levels of
32768; 8-bit results are identical to Photoshop's for 99 % of values.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Format: one rustfmt change main needs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
New Perspective Crop tool in the Crop flyout (C): drag a box or click four
corners, drag corners independently onto a skewed object, perspective grid
(Show Grid), Enter commits, Esc cancels. W/H/Resolution come from the Crop
tool's fields (Front Image, Clear), or the quad's mean side lengths.
Engine: image.perspectiveCrop {corners, width?, height?, resolution?,
interpolation?} maps the quad onto an upright canvas through the
quad-to-rectangle homography (Heckbert's square-to-quad map, inverted):
every raster layer, mask, alpha channel and the Quick Mask, one undo step.
The Background stays opaque over the background colour; type, shape, fill
and smart object layers are rasterized first; vector geometry follows the
nearest affine map. Non-convex, self-crossing, counter-clockwise or
degenerate quads are refused with an error.
algo: warp_surface_clipped bounds the per-tile warp by the new canvas even
when a layer reaches past the map's horizon; convex_quad, quad_size,
quad_to_rect.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
image.crop takes optional targetWidth/targetHeight (px), resolution (ppi)
and resample: the cropped area is resampled to exactly that size (the
Image Size code path, now shared) and the resolution set, in the same
undo step, also after a turned crop. The Crop options bar gets
W x H x Resolution with unit-aware W/H fields, a px/in or px/cm
resolution, Photoshop's built-in size presets, Front Image (and I) and
Clear; the frame holds W:H while drawn or resized, X and the swap button
swap W and H.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>