diff --git a/AGENTS.md b/AGENTS.md
index 69a6b86..c9ef0f1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -138,8 +138,9 @@ Platform adaptations, each forced by a missing Qt analog:
- Photoshop text (12.11b2, Swift 1.2.10: `b8cce6e`, `fa2b342`): `IO/PSD/PSDText.h/.cpp` twin Swift's file, a namespace; Swift's 682 lines split into `PSDText+Engine` (the text engine's PostScript dictionary, a `std::variant` tree read through `walk`, `number`, `boolean`, `string` and `array`) and `PSDText+Descriptor` (Adobe's action descriptors, `Reader` and the item lookups; a rectangle is a normalised `QRectF`, and a side's key is chosen by presence before its type is read, Swift's `??`). A type layer (`TySh`, else `tySh`; 8,000,000 bytes at most) reads as Swift's: version 1, a finite matrix, version 50, the text descriptor; vertical text, shear and uneven scale refuse (the matrix's scale alone maps engine sizes to pixels, never the document's resolution); a warp other than none, faux bold or italic, runs whose twelve-field signature differs from the first, and justification other than left, right or centre each add Swift's note; the words come from `Txt ` (else `Txt`), cleaned of a leading BOM or NUL, trailing NULs and CR line ends, and only when that item is missing from the engine's `EngineDict/Editor/Text`; 1 to 100,000 UTF-16 units. A frame (`bounds`) wider and taller than the letters (`boundingBox`) by more than four makes box text, its corner mapped through the matrix; rectangles read standardised, as `CGRect` reads them. `render` draws through `EditorSession::textImage` and places the anchor (the frame's padded corner, or point text's first baseline at its alignment's x, `TextLines::baseline`, new here) on the document point through the layer transform, a transform that is not valid being `ProjectError(tooLarge)`. The reader parses type first, so a parsed type layer never becomes a live shape or a rasterized vector mask; the builder draws the text (any `std::runtime_error` keeps the pixels with Swift's rasterized note, Swift's `try?`), reports the parser's notes and a missing face, and makes a live text layer. A missing face is one fontconfig does not list by PostScript name (`TextLayout::availableFonts`), where Swift asks `NSFont`; its note says the text was drawn with a substitute font, since Qt draws fontconfig's match where macOS draws the system font. Engine numbers read as Swift's `Double(text)` (whole token, C locale, overflow infinite, through `strtod_l`); a name with a high byte reads empty and an identifier with one refuses, Swift's ASCII decoding; an odd count of UTF-16 bytes in an engine string reads empty, and a lone surrogate in either reader becomes U+FFFD (Qt's decoder keeps it; Swift's strings repair it), a valid pair kept. Fixes beyond Swift: nesting deeper than 512 in either reader refuses the block, where Swift's recursion overflows its stack, and a font index or justification past `Int64` reads as absent (left, with the note), where Swift's `Int()` traps. Left out as dead: `placement`'s finite-scale guard (a scale over 1e-6 from finite numbers), `parse`'s font-size guard (every path clamps it), the inner frame guard (the final `isValid` refuses the same style), `baseline`'s empty sample and no-glyph fallback (the words are never empty, and point text's image holds its first line), the frame's `width > 1` and `height > 1` (each side already passes the letters' by four) and `Double(text)`'s empty token (a number starts with a byte it keeps). Equivalent mutants: `runs.size() > 1` as `> 0` (it only keeps `begin() + 1` off an empty list), `rounded`'s `Int64` bound (past it the cast is undefined, and x86 yields a value no index or justification matches), the string's end after a lone backslash (the dictionary cannot close then) and the retry's `start > 0` (a retry at zero fails again). Tests: `PSDTextTests` (Swift's ten, the builder's notes, rendered pixels and exact transform, the matrix's two per cent, each signature field, a rendered frame's corner, the baseline in ink, frames placed and flipped, the style's fallbacks, a type layer with a shape), `PSDTextReaderTests` (the engine's forms, deep nesting, every descriptor type and cap, the list and reference counts, nesting and names, lone surrogates, colours in one, three and four channels, each channel as a second style, the sources' precedence, rounding, scaled leading, the `Txt` key, negative exponents, both versions and the limits at their edges); `PSDFixture+Text.h` twins `tySh`, and `PSDFixture::data` now takes several additional blocks.
- Text at the click, text on the Move tool's double click, and SVG (12.11c, Swift 1.2.10: `292cb6b`, `072fa08`, `a5721b2`, `0534e00`): a click with the Type tool puts new text's first baseline on the pointer, the box its padding to the left and its first baseline above (`TextLines::baseline(0)` of the style, the laid line's descent, where Swift reads the font's descender: they agree within a hundredth of a pixel); a dragged box sits exactly where it was drawn. Swift's `setHiddenUntilMouseMoves(false)` has no twin: Qt never hides the pointer while typing. The canvas's press is `press(event, clicks)`, Swift's `mouseDown` with its click count: `mousePressEvent` passes one and a double click's fall-through two, so a triple click counts one (the Type tool keeps its own count); with the Move tool a second click on the topmost shown live text under the pointer (a hidden folder hides what it holds) runs `commitTransform`, selects it, opens its editor and synchronizes the inline editor (`beginLiveTextEdit`, in `EditorCanvas+Transform.cpp`), else the press goes on to guides and the transform drag. No transform edit is open there (`canEditLayers`), but `commitTransform` also ends an opacity drag and a blend preview, which `selectLayer` leaves when the text is already active. In the layer list a live text row's size column says "Text · Double-click to edit"; a double click on its thumbnail opens it and on its name renames it, as Swift's merge (`3258c66`) kept, where the branch commit opened text anywhere. Opening existing text puts the caret after it once the editor takes the keys, unless a click already placed it (that merge's step in `InlineTextEditor.synchronize`): the queued focus request moves an untouched caret (0, 0) to the end, for a draft on a layer alone; the editor keeps its canvas as a `CanvasView` to reach itself there. SVG goes by its suffix, `svg` or `svgz` in any case (the importer's filter lists both, the desktop entry `image/svg+xml` and `image/svg+xml-compressed`, which shared-mime-info keeps apart) (Swift's `UTType.svg`), after camera raw and before Photoshop's bytes, through the ordinary decoder with the canvas's size: `ImageImporter::decodeSVG` renders once through Qt SVG's `QSvgRenderer` (a new dependency: `qt6-svg-dev` in the dev image, `qt6-svg`, `qt6-qtsvg-devel` and `qt6.qtsvg` in the distro images, the PKGBUILD and the flake; the DEB and RPM find the library themselves), fitted to the canvas by the smaller ratio, else at the declared size, each side rounded and one pixel at least, refused past the side limit or the remaining budget, tagged sRGB, named without its suffix. Drops need nothing, since a dropped picture of any `image/` type already imports. Known differences: Qt SVG draws SVG Tiny 1.2 where macOS's renderer draws more of SVG 1.1 (filters, masks, some CSS), `defaultSize` truncates a declared size to whole pixels (40.9 by 20.9 draws 40 by 20; a side that truncates to nothing falls back to Qt's bounds, which round, so a declared side under half a pixel refuses and one from half a pixel draws a pixel wide, where Swift draws any positive side a pixel wide at least), and a drawing whose declared size is empty takes its content's bounds, where `NSImage` measures nothing; Swift's `isValid` guard is folded into the size test, since Qt measures a drawing it cannot read as nothing. Tests: Swift's two `TypeToolTests` changes and the baseline in ink (`clickedTextSitsOnItsBaseline`); `LiveTextDoubleClickTests` (zoomed and turned text, a hidden folder, the cleanup, the caret); `SVGImportTests`. Tests that set text up at a known corner use `beginTextAt` (`SessionFixtures.h`), which pins the draft's corner as Swift's clipping test does.
- Typed text as pixels, effects kept on (12.12a, Swift 1.2.11: `fe6cb2b`, `b683acb`): the canvas draws text being typed as the pixels `textImage` makes of the draft's style (remade only when the style changes and let go at the first draw without a draft, as Swift's `drawNewText` asks with no draft, each remake a debug line on `omaphoto.rendering`, a style that cannot be drawn logged and drawn as nothing), where the editor shows it: an edited layer in its place with its opacity and blend mode (its mask left off, as Swift's), new text just above the active layer, or on top when the active layer is not drawn (a folder, a hidden or blank layer, none). The editor's glyphs are clear: it draws its frame, handles, caret, marked text's underline and a selection at 45% of the highlight over those pixels. An underline without a colour of its own, the preedit's or an input method's, takes the text's colour, since clear glyphs would hide it (Swift's `NSTextView` draws marked text itself). `DisplayState` carries the draft's style and shown transform, so typing repaints the whole view, as effects reach past the box; a handle drag at a zoom can move the box a fraction with the style unchanged, so the transform counts on its own. The edited layer's effects are redone as it is typed, synchronously at the preview size (`EffectsPreviewCache::renderNow`, the worker's scaling moved into `scaled`), through its enabled mask or its placed mask clipped over the typed box; until a change is redone the last effects stand in. When the edit ends and the layer holds the text as last typed, the next draw seeds those effects (`handOnDraftEffects`); a cancel of changed text, or other text, seeds nothing. The cache keeps the last three finished results per layer: pixels an undo puts back show their effects at once, a known result clearing any seed; a mask added, removed or replaced on the same pixels keeps the last preview until the new one lands, where it dropped before. A painted mask keeps its layer's effects through the stroke: `LayerEffectsSurface::MaskStroke` holds the stroke's tiles, their map into the surface's grid and the coverage of any region (the old mask, then the tiles over it, as a tile already holds the old mask it covers; Swift's white fill beneath is dead, since past the old mask there are no pixels), and the surface redoes the regions the mapped tiles touch; a placed mask's surface keeps the layer's grid and takes coverage from `placedMaskPreview` (`placedMaskSurface`). Swift's `restoreToolCursor` has no twin: `syncPicking` already sets the cursor both ways, and a widget's cursor cannot be left over from another window. `strokeDirtyRect` moved to `EditorCanvas+Brush.cpp`, the typed text to `EditorCanvas+DraftText.cpp`. Left out as dead: the cache's filter of a result already recent (a request matching one returns before it renders). Equivalent mutant: the mapped tile rect's one-pixel inset (the redo region is widened by the effects' reach). Tests: `TypedTextCanvasTests` (Swift's `textLooksTheSameWhileEditingAndOnceCommitted` at 1.5 and 4, with the box's inside compared since the editor's frame draws on the canvas; placement above the active layer, opacity and a blend mode with and without effects, effects kept and handed on, masks linked and placed, cancelled text, remakes counted and the image let go once editing ends, typed effects redone when a handle moves a placed-mask box a fraction, the whole view repainted when typing or when a handle moves the box a fraction), `MaskEffectsCanvasTests` (linked and placed masks keep effects mid-stroke, a placed mask sixty times finer against a whole render; untouched tiles keep the old mask), `EffectsPreviewCacheTests` (recent results, `renderNow`, the looser keep), `LayerEffectsSurfaceTests` (a mask stroke redone where its tiles map), and the inline editor's tests read the 45% selection through `highlightedOver`.
+- Masks painted anywhere (12.12b, Swift 1.2.11: `e753f26`, `8d2eade`, `2ffb092`, `a247676`, taken as their net change from 1.2.10): `BrushStroke` takes `growsMask`, which the Brush, the Gradient and fills (`applyPixelEdit`) pass, so on a mask their grid reaches the canvas as a pixel stroke's does; Spot Healing, Clone Stamp and the Smear keep the mask's grid. A solid placed mask (two pixels a side at most) is painted at a pixel per document pixel of its placement rather than grown from its pixel. `maskBackground` (`LayerMask::background` of the old mask's thumbnail, white or black, 1 without a mask) fills each new tile, the committed snapshot past its base and patches (`RasterSnapshot::fill`, drawn where neither reaches, the box of a mask grown by distant tiles holding such corners) and the asynchronous commit's context (`BrushCommit::Input::fill`). A mask stroke's snapshot keeps every touched tile (`committedBounds`). Committed, a mask grown past its old bounds, or placed already, takes the stroke's transform as its placement (`placedMask`, for the snapshot and the raster commit alike), keeping its place on the document while a linked one still moves with its layer. On the canvas an unplaced mask stroke draws in its possibly grown grid (`paintTransform`), a placed one in the layer's place, and a placed mask's preview and surface map through the stroke's `paintTransform`. `CanvasThumbnail::mask` fills past the mask with `LayerMask::background`, so a stroke touching the edge leaves no gray mean; the thumbnail's own `edgeTone` is gone. `8d2eade`'s canvas fix (the distortion's effects asked with the mask as stored) needs no twin: the twin asks for a layer's effects once, with the mask as shown (9.6c), and its y-flip in `LayerEffectsSurface` has no Qt counterpart. The linked mask surface's white fill (12.12a) stays dead: the layer's pixels lie only within the old mask's rect, however far the grid grows. Equivalent mutant: `placedMask`'s `mask.placement` term (a placed mask keeps its placement through `replacing`, and its transform over unchanged bounds is that placement). Tests: `MaskPaintAnywhereTests` (the brush grows a mask and keeps it linked and in place, a hide-all mask's new area hides, the Gradient and a fill grow it, the Smear's blur does not, a solid placed mask's grid (two pixels a side, a box not square, grown and not), the snapshot's and the commit's fill in uncovered corners, a grown stroke drawn in place, a grown placed mask's tiles mapped under effects), `CanvasThumbnailTests` (Swift's edge case), and `MaskEffectsCanvasTests`' finer placed mask through the stroke's transform.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to
"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
-- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
+- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
- `CompositorMenus` builds the window's `QMenuBar` from Swift's `.commands`, entry for entry, as far as the session functions exist (`TASKS.md`, "Menu items owed", lists the rest); ⌘ is Ctrl and ⌥ is Alt in every shortcut. Its entries follow the front tab's session, like the toolbar. Swift's Undo and Redo keep a text field's own undo while one has focus, so the twin's do the same: with a `QLineEdit` in focus the entries are the field's, titled bare and always enabled; otherwise they carry the step's name, and only while the session's history is usable. Focus is state the session does not announce, so the menus also sync on `QApplication::focusChanged`. Keyboard navigation (Alt+E) hands focus to the menu bar itself, so the menus keep the field that held it until focus lands anywhere else. The View menu's Pixel Grid entry is checkable and follows `showsPixelGrid`. `Close Project` closes the tab (Swift's `close(window)` hands over to the workspace); `Delete Layer` is Swift's `deleteLayerOrMask`, which takes a chosen effect first since 9.6a; since 9.6d its title names that effect (`Delete Stroke`). The `Hide` entries and `Check for Updates` have no twin (the desktop hides windows; distro packages update).
- The session's gates (`canEditLayers`, `canUseHistory`) and its `selectLayer`, `selectTool`, `undo` and `restore` read only the state that exists so far. `TASKS.md` lists, under "Session hooks owed", the term each later item adds. `reorderLayers` takes a `std::set` for `IndexSet` and reproduces SwiftUI's `move(fromOffsets:toOffset:)`.
diff --git a/TASKS.md b/TASKS.md
index d53a054..8523b7d 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -383,7 +383,7 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- [x] 12.11b2 Part three: simple Photoshop text as editable text (`b8cce6e`, `fa2b342`). Codex review: 3 rounds; the third's findings (test gaps: rounding, scaled leading, the `Txt` key, negative exponents, versions, the limits' edges) fixed and closed.
- [x] 12.11c Part four: clicked text starting on its first baseline, the pointer kept while typing (`292cb6b`, `072fa08`); live text opened by a double click with the Move tool (`a5721b2`); SVG import through Qt SVG (`0534e00`). Codex review: 3 rounds; the third's one finding (a test gap: a stale caret step meeting a replacing draft) fixed and closed.
- [x] 12.12a Version 1.2.11, part one: text being typed shown as the pixels it will commit to (`fe6cb2b`); layer effects kept on screen while text is edited, masks are painted and steps undone (`b683acb`). Codex review: 3 rounds, PASS.
-- [ ] 12.12b Part two: masks painted anywhere on the canvas (`e753f26`, `2ffb092`), a moved unlinked mask kept in place under effects (`8d2eade`), mask thumbnails' ground white or black as the canvas treats it (`a247676`).
+- [x] 12.12b Part two: masks painted anywhere on the canvas (`e753f26`, `2ffb092`), a moved unlinked mask kept in place under effects (`8d2eade`), mask thumbnails' ground white or black as the canvas treats it (`a247676`). Codex review: 1 round; its one finding (two long test comments) fixed.
- [ ] 12.12c Part three: numeric label scrubbing (`2d36d6e`, `680a522`); the build-warning fixes (`075a71b`, `725b934`) where they change behaviour.
- [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects.
- [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish.
diff --git a/src/Document/BrushStroke+Commit.cpp b/src/Document/BrushStroke+Commit.cpp
index 1c921bd..51ba0a6 100644
--- a/src/Document/BrushStroke+Commit.cpp
+++ b/src/Document/BrushStroke+Commit.cpp
@@ -131,8 +131,9 @@ PaintSnapshot BrushStroke::paintSnapshot() const
const QRectF placed = rect.translated(tile.rect.topLeft());
bounds = bounds ? std::optional(bounds->united(placed)) : std::optional(placed);
}
- const QRectF crop = bounds.value_or(committedBounds());
- const std::shared_ptr raster = RasterSnapshot::replacing(m_source, sourceRect, patches(), crop, isMask);
+ // A mask keeps every tile touched: past its pixels, grows.
+ const QRectF crop = isMask ? committedBounds() : bounds.value_or(committedBounds());
+ const std::shared_ptr raster = RasterSnapshot::replacing(m_source, sourceRect, patches(), crop, isMask, maskBackground);
return {ImportedImage(raster, raster->thumbnail(), layer.name), transform(crop), crop};
}
@@ -142,7 +143,7 @@ BrushCommit::Input BrushStroke::commitInput() const
std::vector shifted;
for (const BrushPatch &patch : patches())
shifted.push_back({patch.rect.translated(-bounds.topLeft()), patch.image});
- return {int(bounds.width()), int(bounds.height()), m_source, shifted, isMask, layer.name, sourceRect.translated(-bounds.topLeft())};
+ return {int(bounds.width()), int(bounds.height()), m_source, shifted, isMask, layer.name, sourceRect.translated(-bounds.topLeft()), maskBackground};
}
ImportedImage BrushCommit::expandMask(const ImportedImage &asset, const Input &input, const QRectF &crop)
@@ -161,6 +162,8 @@ ImportedImage BrushCommit::expandMask(const ImportedImage &asset, const Input &i
BrushCommit::Output BrushCommit::render(const Input &input)
{
QImage context = BrushRaster::context(input.width, input.height, input.mask);
+ if (input.mask)
+ context.fill(qRound(input.fill * 255));
QPainter painter(&context);
if (input.source)
drawSource(*input.source, input.sourceRect, painter);
diff --git a/src/Document/BrushStroke+Stroke.cpp b/src/Document/BrushStroke+Stroke.cpp
index f319b5f..5a2f4a6 100644
--- a/src/Document/BrushStroke+Stroke.cpp
+++ b/src/Document/BrushStroke+Stroke.cpp
@@ -64,20 +64,26 @@ double BrushStroke::spacingFraction(double hardness)
return hardness >= 1 ? 0.015 : 0.025;
}
-BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas)
+BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, bool growsMask)
{
// A placed mask paints in its grid; else the layer's.
const bool placedMask = mask && layer.mask && layer.mask->placement;
const LayerTransform base = placedMask ? *layer.mask->placement : layer.transform;
- const int originalWidth = placedMask ? layer.mask->asset.size().width()
- : layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width()));
- const int originalHeight = placedMask ? layer.mask->asset.size().height()
- : layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height()));
+ // A solid placed mask gets a pixel per document pixel.
+ const bool solidPlaced = placedMask && layer.mask->asset.size().width() <= 2 && layer.mask->asset.size().height() <= 2;
+ const int originalWidth = solidPlaced ? std::max(1, int(std::round(base.size.width())))
+ : placedMask ? layer.mask->asset.size().width()
+ : layer.asset ? layer.asset->size().width()
+ : int(std::round(layer.size().width()));
+ const int originalHeight = solidPlaced ? std::max(1, int(std::round(base.size.height())))
+ : placedMask ? layer.mask->asset.size().height()
+ : layer.asset ? layer.asset->size().height()
+ : int(std::round(layer.size().height()));
if (originalWidth < 1 || originalWidth > DocumentLimits::maxSide || originalHeight < 1 || originalHeight > DocumentLimits::maxSide)
throw ProjectError(ProjectError::Kind::tooLarge);
const QTransform originalMapping = BrushRaster::pixelToDocument(base, originalWidth, originalHeight);
const QRectF originalBounds(0, 0, originalWidth, originalHeight);
- const QRectF extent = mask ? originalBounds : originalBounds.united(integral(originalMapping.inverted().mapRect(canvas)));
+ const QRectF extent = mask && !growsMask ? originalBounds : originalBounds.united(integral(originalMapping.inverted().mapRect(canvas)));
// Judged as a double: past int the conversion is undefined.
if (extent.width() < 1 || extent.width() > 1'000'000'000 || extent.height() < 1 || extent.height() > 1'000'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
@@ -93,14 +99,15 @@ BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const Br
return grid;
}
-BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas)
- : BrushStroke(layer, mask, settings, QRectF(QPointF(0, 0), canvas), grid(layer, mask, settings, QRectF(QPointF(0, 0), canvas)))
+BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas, bool growsMask)
+ : BrushStroke(layer, mask, settings, QRectF(QPointF(0, 0), canvas), grid(layer, mask, settings, QRectF(QPointF(0, 0), canvas), growsMask))
{
}
BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid)
: layer(layer), isMask(mask), settings(settings), canvas(canvas), width(grid.width), height(grid.height), sourceRect(grid.sourceRect),
pixelToDocument(grid.pixelToDocument), paintTransform(grid.paintTransform),
+ maskBackground(mask && layer.mask ? LayerMask::background(layer.mask->asset.thumbnail) : 1),
m_source(mask ? (layer.mask ? std::optional(layer.mask->asset) : std::nullopt) : layer.asset),
m_paintColor(mask ? QColor::fromRgbF(settings.red, settings.red, settings.red) : QColor::fromRgbF(settings.red, settings.green, settings.blue))
{
@@ -306,6 +313,9 @@ void BrushStroke::allocateTile(qint64 key, qint64 x, qint64 y)
throw ProjectError(ProjectError::Kind::tooLarge);
m_allocatedBounds = nextBounds;
QImage context = BrushRaster::context(int(rect.width()), int(rect.height()), isMask);
+ // Past the mask's pixels a tile starts as its background.
+ if (isMask)
+ context.fill(qRound(maskBackground * 255));
QPainter painter(&context);
const QRectF initialRect = sourceRect.translated(-rect.topLeft());
if (m_source && m_source->raster) {
diff --git a/src/Document/BrushStroke.h b/src/Document/BrushStroke.h
index ab606fa..8c82c01 100644
--- a/src/Document/BrushStroke.h
+++ b/src/Document/BrushStroke.h
@@ -75,6 +75,8 @@ struct Input {
bool mask;
QString name;
QRectF sourceRect;
+ // A grown mask where no old pixel or edit reaches.
+ double fill = 1;
};
struct Output {
ImportedImage asset;
@@ -87,7 +89,8 @@ Output render(const Input &input);
// Only touched 256 px tiles hold pixels; snapshots copy those.
class BrushStroke {
public:
- BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas);
+ // `growsMask`: the brush and fills reach the canvas on masks.
+ BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas, bool growsMask = false);
const ImageLayer layer;
const bool isMask;
@@ -98,6 +101,8 @@ public:
const QRectF sourceRect;
const QTransform pixelToDocument;
const LayerTransform paintTransform;
+ // A mask past its pixels: 1 reveals, 0 hides.
+ const double maskBackground;
// Swift's Int: the budget left may run below zero.
qint64 pixelLimit = DocumentLimits::documentPixelBudget();
// Limits every edit to the document selection; nil when none.
@@ -163,7 +168,7 @@ private:
QTransform pixelToDocument;
LayerTransform paintTransform;
};
- static Grid grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas);
+ static Grid grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, bool growsMask);
BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid);
std::vector continuousCurve(QPointF start, QPointF end, QPointF before, QPointF after) const;
std::set continuousKeys(const std::vector &segments) const;
diff --git a/src/Document/EditorSession+Brush.cpp b/src/Document/EditorSession+Brush.cpp
index 20be1d6..c90295f 100644
--- a/src/Document/EditorSession+Brush.cpp
+++ b/src/Document/EditorSession+Brush.cpp
@@ -38,11 +38,11 @@ bool EditorSession::canPaint() const
}
// A tiled raster edit of pixels or mask, within budget.
-std::unique_ptr EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings) const
+std::unique_ptr EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings, bool growsMask) const
{
if (!m_document)
throw ProjectError(ProjectError::Kind::tooLarge);
- auto stroke = std::make_unique(layer, m_isMaskSelected, settings, m_document->size());
+ auto stroke = std::make_unique(layer, m_isMaskSelected, settings, m_document->size(), growsMask);
qint64 used = 0;
for (const ImageLayer &other : m_document->layers) {
if (other.id == layer.id)
@@ -108,7 +108,7 @@ void EditorSession::beginBrush(QPointF point)
settings.green = settings.red;
settings.blue = settings.red;
}
- m_brushStroke = makeRasterEdit(layer, settings);
+ m_brushStroke = makeRasterEdit(layer, settings, m_tool == NavigationTool::brush);
m_brushStroke->clone = clone;
m_brushStroke->isBlur = m_tool == NavigationTool::blur;
m_brushStroke->append(point);
@@ -225,6 +225,16 @@ void EditorSession::finishBrush()
finishBrushImmediately();
}
+// Grown past its layer, or placed already: keeps its place.
+LayerMask EditorSession::placedMask(const LayerMask &mask, const ImportedImage &asset, const QRectF &bounds, const LayerTransform &transform,
+ const BrushStroke &stroke)
+{
+ LayerMask painted = mask.replacing(asset);
+ if (mask.placement || bounds != stroke.sourceRect)
+ painted.placement = transform;
+ return painted;
+}
+
// Immutable tiles land at once, undo entry included.
void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
{
@@ -249,7 +259,7 @@ void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
: QStringLiteral("Brush Stroke")));
ImageLayer &layer = m_document->layers[index];
if (stroke.isMask) {
- layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset);
+ layer.mask = current.mask ? placedMask(*current.mask, result.asset, result.bounds, result.transform, stroke) : LayerMask(result.asset);
} else {
layer.asset = result.asset;
layer.transform = result.transform;
@@ -327,7 +337,8 @@ void EditorSession::finishRasterCommit()
beginEdit(pending.name);
ImageLayer &layer = m_document->layers[index];
if (stroke.isMask) {
- layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset);
+ const QRectF bounds = result.pixelBounds.translated(stroke.committedBounds().topLeft());
+ layer.mask = current.mask ? placedMask(*current.mask, result.asset, bounds, transform, stroke) : LayerMask(result.asset);
} else {
layer.asset = result.asset;
layer.transform = transform;
diff --git a/src/Document/EditorSession+Brush.h b/src/Document/EditorSession+Brush.h
index c6433b5..a94f37f 100644
--- a/src/Document/EditorSession+Brush.h
+++ b/src/Document/EditorSession+Brush.h
@@ -1,7 +1,7 @@
// Swift's EditorSession+Brush extension: a stroke from press to commit.
public:
bool canPaint() const;
- std::unique_ptr makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings()) const;
+ std::unique_ptr makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings(), bool growsMask = false) const;
void beginBrush(QPointF point);
void continueBrush(QPointF point);
// Where a Shift-click's line starts; nil on another target.
@@ -22,5 +22,8 @@ public:
private:
void finishRasterCommit();
+ // A mask painted: grown or placed, it keeps its place.
+ static LayerMask placedMask(const LayerMask &mask, const ImportedImage &asset, const QRectF &bounds, const LayerTransform &transform,
+ const BrushStroke &stroke);
// Where Smoothing lets the brush go; none while slack.
std::optional smoothed(QPointF point);
diff --git a/src/Document/Gradient.cpp b/src/Document/Gradient.cpp
index efba42f..5ac496d 100644
--- a/src/Document/Gradient.cpp
+++ b/src/Document/Gradient.cpp
@@ -48,7 +48,7 @@ void EditorSession::beginGradient(QPointF point)
return;
finishOpacityEdit();
try {
- m_gradientEdit = GradientEdit{std::shared_ptr(makeRasterEdit(*layer)), point, point};
+ m_gradientEdit = GradientEdit{std::shared_ptr(makeRasterEdit(*layer, BrushSettings(), true)), point, point};
++m_brushRevision;
notify();
} catch (const ProjectError &error) {
diff --git a/src/Document/SelectionEdits.cpp b/src/Document/SelectionEdits.cpp
index d404f4c..9ec3603 100644
--- a/src/Document/SelectionEdits.cpp
+++ b/src/Document/SelectionEdits.cpp
@@ -86,7 +86,8 @@ void EditorSession::applyPixelEdit(const ImageLayer &layer, const QString &name,
{
finishOpacityEdit();
try {
- std::unique_ptr edit = makeRasterEdit(layer);
+ // A fill on a mask covers the canvas too.
+ std::unique_ptr edit = makeRasterEdit(layer, BrushSettings(), true);
paint(*edit);
if (edit->patches().empty()) {
if (done)
diff --git a/src/Rendering/EditorCanvas+Effects.cpp b/src/Rendering/EditorCanvas+Effects.cpp
index 40fe191..2c9bfe3 100644
--- a/src/Rendering/EditorCanvas+Effects.cpp
+++ b/src/Rendering/EditorCanvas+Effects.cpp
@@ -42,8 +42,8 @@ void CanvasView::drawStroke(const BrushStroke &stroke, const ImageLayer &layer,
// A placed mask: the layer draws through its own grid.
if (!layer.asset)
return;
- const std::optional preview = stroke.placedMaskPreview(*stroke.layer.mask->placement);
- if (preview && drawSurface(placedMaskSurface(layer, stroke, *stroke.layer.mask->placement, *preview)))
+ const std::optional preview = stroke.placedMaskPreview(stroke.paintTransform);
+ if (preview && drawSurface(placedMaskSurface(layer, stroke, *preview)))
return;
LayerRenderer::Options through = options;
through.mask = preview.value_or(QImage());
@@ -95,7 +95,7 @@ LayerEffectsSurface *CanvasView::strokeSurface(const ImageLayer &layer, const Br
}
// A placed mask paints its grid; the surface, the layer's.
-LayerEffectsSurface *CanvasView::placedMaskSurface(const ImageLayer &layer, const BrushStroke &stroke, const LayerTransform &placement, const QImage &preview)
+LayerEffectsSurface *CanvasView::placedMaskSurface(const ImageLayer &layer, const BrushStroke &stroke, const QImage &preview)
{
const LayerEffects effects = layer.effects.value_or(LayerEffects()).visible();
if (effects.isEmpty() || !effects.isValid() || !stroke.layer.asset)
@@ -106,7 +106,7 @@ LayerEffectsSurface *CanvasView::placedMaskSurface(const ImageLayer &layer, cons
m_strokeSurface = LayerEffectsSurface::make(layer.id, effects, full.size(), full);
if (!m_strokeSurface)
return nullptr;
- const QTransform toGrid = BrushRaster::pixelToDocument(placement, stroke.width, stroke.height)
+ const QTransform toGrid = BrushRaster::pixelToDocument(stroke.paintTransform, stroke.width, stroke.height)
* BrushRaster::pixelToDocument(stroke.layer.transform, base.width(), base.height()).inverted();
m_strokeSurface->update(stroke.layer.asset, {}, std::nullopt, LayerEffectsSurface::MaskStroke{stroke.patches(), toGrid, [preview, full](const QRectF ®ion) {
QImage coverage = BrushRaster::context(int(region.width()), int(region.height()), true);
diff --git a/src/Rendering/EditorCanvas.cpp b/src/Rendering/EditorCanvas.cpp
index 7657109..0dd4518 100644
--- a/src/Rendering/EditorCanvas.cpp
+++ b/src/Rendering/EditorCanvas.cpp
@@ -342,8 +342,9 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
.mask = mask.value_or(QImage()), .clip = clip});
return;
}
- // A pixel stroke draws its grid by its own transform.
- const LayerTransform transform = stroke && !stroke->isMask ? stroke->paintTransform : m_session.displayedTransform(layer);
+ // A stroke draws its grid, a placed mask's the layer's.
+ const bool placedStroke = stroke && stroke->isMask && stroke->layer.mask && stroke->layer.mask->placement;
+ const LayerTransform transform = stroke && !placedStroke ? stroke->paintTransform : m_session.displayedTransform(layer);
const std::optional placement = m_session.displayedMaskPlacement(layer);
std::optional mask;
if (const std::optional distorted = m_session.maskDistortPreview(layer)) {
diff --git a/src/Rendering/EditorCanvas.h b/src/Rendering/EditorCanvas.h
index ae62d40..3185ef1 100644
--- a/src/Rendering/EditorCanvas.h
+++ b/src/Rendering/EditorCanvas.h
@@ -156,7 +156,7 @@ private:
void drawNewText(bool &drawn, double scale, const Center ¢er, QPainter &target, const QImage &clip);
void handOnDraftEffects(const CanvasDocument &document);
// Swift's effects surface on the canvas (EditorCanvas+Effects.cpp).
- LayerEffectsSurface *placedMaskSurface(const ImageLayer &layer, const BrushStroke &stroke, const LayerTransform &placement, const QImage &preview);
+ LayerEffectsSurface *placedMaskSurface(const ImageLayer &layer, const BrushStroke &stroke, const QImage &preview);
LayerEffectsSurface *strokeSurface(const ImageLayer &layer, const BrushStroke &stroke, const std::optional &mask);
// The stroke is over: its surface stands in meanwhile.
void handOnStrokeSurface();
diff --git a/src/Rendering/RasterSnapshot.cpp b/src/Rendering/RasterSnapshot.cpp
index c1fd521..2b6f0ac 100644
--- a/src/Rendering/RasterSnapshot.cpp
+++ b/src/Rendering/RasterSnapshot.cpp
@@ -48,16 +48,16 @@ std::vector without(const BrushPatch &piece, const QRectF &overlap)
}
RasterSnapshot::RasterSnapshot(int width, int height, QImage base, QRectF baseRect, std::vector patches,
- bool isMask, std::optional alignment)
+ bool isMask, std::optional alignment, double fill)
: width(width), height(height), base(std::move(base)), baseRect(baseRect), patches(std::move(patches)),
- isMask(isMask), alignment(alignment.value_or(baseRect.topLeft()))
+ isMask(isMask), alignment(alignment.value_or(baseRect.topLeft())), fill(fill)
{
}
std::shared_ptr RasterSnapshot::replacing(const std::optional &source,
const QRectF &sourceRect,
const std::vector &added,
- const QRectF &crop, bool isMask)
+ const QRectF &crop, bool isMask, double fill)
{
const std::shared_ptr old = source ? source->raster : nullptr;
const QPointF shift = sourceRect.topLeft() - crop.topLeft();
@@ -107,7 +107,7 @@ std::shared_ptr RasterSnapshot::replacing(const std::optio
const QRectF baseRect = old ? old->baseRect.translated(shift) : sourceRect.translated(-crop.topLeft());
const QPointF alignment = sourceRect.topLeft() + (old ? old->alignment : QPointF(0, 0)) - crop.topLeft();
return std::make_shared(int(crop.width()), int(crop.height()), base, baseRect,
- std::move(inside), isMask, alignment);
+ std::move(inside), isMask, alignment, fill);
}
void RasterSnapshot::draw(const QRectF &rect, QPainter &context) const
@@ -115,9 +115,9 @@ void RasterSnapshot::draw(const QRectF &rect, QPainter &context) const
context.save();
context.setClipRect(rect, Qt::IntersectClip);
context.setRenderHint(QPainter::Antialiasing, false);
- // A grown mask reveals the pixels outside it.
+ // Past its pixels a grown mask is its background.
if (isMask)
- context.fillRect(rect, Qt::white);
+ context.fillRect(rect, QColor::fromRgbF(float(fill), float(fill), float(fill)));
const double sx = rect.width() / width, sy = rect.height() / height;
const auto mapped = [&](const QRectF &r) {
return QRectF(rect.left() + r.left() * sx, rect.top() + r.top() * sy, r.width() * sx, r.height() * sy);
diff --git a/src/Rendering/RasterSnapshot.h b/src/Rendering/RasterSnapshot.h
index 1a1cf1c..cfe0d82 100644
--- a/src/Rendering/RasterSnapshot.h
+++ b/src/Rendering/RasterSnapshot.h
@@ -17,14 +17,16 @@ public:
const bool isMask;
// Origin of the halving grids, kept across commits.
const QPointF alignment;
+ // A mask beyond its base and patches: shown or hidden.
+ const double fill;
RasterSnapshot(int width, int height, QImage base, QRectF baseRect, std::vector patches,
- bool isMask = false, std::optional alignment = std::nullopt);
+ bool isMask = false, std::optional alignment = std::nullopt, double fill = 1);
static std::shared_ptr replacing(const std::optional &source,
const QRectF &sourceRect,
const std::vector &patches,
- const QRectF &crop, bool isMask = false);
+ const QRectF &crop, bool isMask = false, double fill = 1);
void draw(const QRectF &rect, QPainter &context) const;
QImage makeImage() const;
diff --git a/src/UI/CanvasThumbnail.cpp b/src/UI/CanvasThumbnail.cpp
index 8569412..a3f5f85 100644
--- a/src/UI/CanvasThumbnail.cpp
+++ b/src/UI/CanvasThumbnail.cpp
@@ -65,27 +65,9 @@ QPixmap CanvasThumbnail::layer(const QImage &image, const LayerTransform &transf
QPixmap CanvasThumbnail::mask(const QImage &image, const LayerTransform &transform, QSizeF canvas, double box)
{
return render(canvas, box, [&](QPainter &painter, QSize size, double scale) {
- const double tone = edgeTone(image);
+ // White or black, as the canvas treats it, never gray.
+ const double tone = LayerMask::background(image);
painter.fillRect(QRect(QPoint(0, 0), size), QColor::fromRgbF(tone, tone, tone));
place(image, transform, scale, painter);
});
}
-
-double CanvasThumbnail::edgeTone(const QImage &image)
-{
- const QImage gray = image.convertToFormat(QImage::Format_Grayscale8);
- if (gray.width() <= 0 || gray.height() <= 0)
- return 1;
- qint64 total = 0, count = 0;
- for (int y = 0; y < gray.height(); ++y) {
- const uchar *row = gray.constScanLine(y);
- const bool edgeRow = y == 0 || y == gray.height() - 1;
- for (int x = 0; x < gray.width(); ++x) {
- if (edgeRow || x == 0 || x == gray.width() - 1) {
- total += row[x];
- ++count;
- }
- }
- }
- return count == 0 ? 1 : double(total) / double(count) / 255;
-}
diff --git a/src/UI/CanvasThumbnail.h b/src/UI/CanvasThumbnail.h
index 48baae4..e315f00 100644
--- a/src/UI/CanvasThumbnail.h
+++ b/src/UI/CanvasThumbnail.h
@@ -13,8 +13,6 @@ inline constexpr double backingScale = 2;
QSize fittedSize(QSizeF canvas, double box);
// The checkerboard with the pixels placed; null: empty canvas.
QPixmap layer(const QImage &image, const LayerTransform &transform, QSizeF canvas, double box);
-// The mask placed; its edge tone fills the rest.
+// The mask placed; its background fills the rest.
QPixmap mask(const QImage &image, const LayerTransform &transform, QSizeF canvas, double box);
-// The mean gray, 0 to 1, of the outermost pixels.
-double edgeTone(const QImage &image);
}
diff --git a/tests/CanvasThumbnailTests.cpp b/tests/CanvasThumbnailTests.cpp
index 8949e28..9cc1e0a 100644
--- a/tests/CanvasThumbnailTests.cpp
+++ b/tests/CanvasThumbnailTests.cpp
@@ -67,16 +67,21 @@ void CanvasThumbnailTests::masksFillTheCanvasWithTheirEdgeTone()
for (int x = 5; x < 15; ++x)
framed.setPixel(x, y, qRgb(0, 0, 0));
}
- QCOMPARE(CanvasThumbnail::edgeTone(framed), 1.0);
+ QCOMPARE(LayerMask::background(framed), 1.0);
const QPixmap shown = CanvasThumbnail::mask(framed, transform, canvas, 30);
QCOMPARE(shown.deviceIndependentSize(), QSizeF(30, 15));
QVERIFY(read(shown, 2, 2)[0] > 245);
QVERIFY(read(shown, 22, 15)[0] < 10);
- // A mid-gray frame reads as its mean.
- QImage half(4, 3, QImage::Format_Grayscale8);
- half.fill(128);
- half.setPixel(1, 1, qRgb(0, 0, 0));
- QCOMPARE(CanvasThumbnail::edgeTone(half), 128.0 / 255);
+ // A stroke reaching the edge: the rest still reads white.
+ for (int y = 8; y < 12; ++y) {
+ for (int x = 0; x < 20; ++x)
+ framed.setPixel(x, y, qRgb(0, 0, 0));
+ }
+ QVERIFY(read(CanvasThumbnail::mask(framed, transform, canvas, 30), 2, 2)[0] > 245);
+ // Mostly black at the edge: black beyond.
+ QImage dark(4, 3, QImage::Format_Grayscale8);
+ dark.fill(100);
+ QVERIFY(read(CanvasThumbnail::mask(dark, transform, canvas, 30), 2, 2)[0] < 10);
}
QTEST_MAIN(CanvasThumbnailTests)
diff --git a/tests/MaskEffectsCanvasTests.cpp b/tests/MaskEffectsCanvasTests.cpp
index 46fb6f2..44c1ea5 100644
--- a/tests/MaskEffectsCanvasTests.cpp
+++ b/tests/MaskEffectsCanvasTests.cpp
@@ -135,7 +135,7 @@ void MaskEffectsCanvasTests::aFinerPlacedMaskMapsIntoTheLayersGrid()
const QImage painting = scene.shot();
// The oracle: a whole render through the stroke's mask.
const ImageLayer square = layerWith(scene.session, scene.square);
- const QImage preview = scene.session.brushStroke()->placedMaskPreview(square.mask.value().placement.value()).value();
+ const QImage preview = scene.session.brushStroke()->placedMaskPreview(scene.session.brushStroke()->paintTransform).value();
const LayerEffectsRenderer::Rendered expected = LayerEffectsRenderer::render(square.asset.value().image(), preview, outline(2));
int largest = 0;
for (int y = 10; y < 30; ++y) {
diff --git a/tests/MaskPaintAnywhereTests.cpp b/tests/MaskPaintAnywhereTests.cpp
new file mode 100644
index 0000000..cb347d7
--- /dev/null
+++ b/tests/MaskPaintAnywhereTests.cpp
@@ -0,0 +1,257 @@
+#include "BrushFixtures.h"
+#include "SelectionFixtures.h"
+#include "SessionFixtures.h"
+#include "Rendering/EditorCanvas.h"
+#include "Rendering/RasterSnapshot.h"
+#include
+
+// Swift 1.2.11: a mask painted anywhere on the canvas grows.
+namespace {
+// A blue square on a wide canvas, its mask chosen.
+std::unique_ptr masked(bool reveal, QSize canvas = QSize(100, 60))
+{
+ auto session = std::make_unique();
+ session->createDocument(canvas.width(), canvas.height(), true);
+ QImage blue = BrushRaster::context(20, 20, false);
+ blue.fill(Qt::blue);
+ session->addPixelLayer(blue, QPointF(20, 20), QStringLiteral("Blue"), QStringLiteral("Add"));
+ session->addLayerMask(reveal);
+ session->selectLayerTarget(session->activeLayerID().value(), true);
+ session->selectTool(NavigationTool::brush);
+ session->setBrushSettings(brush(6, 1, 0, 0, 0));
+ return session;
+}
+
+// The mask's value at a document point, through its placement.
+int maskAt(const EditorSession &session, QPointF point)
+{
+ const LayerMask mask = session.activeLayer().value().mask.value();
+ const LayerTransform placement = mask.placement.value();
+ const QImage image = mask.asset.image();
+ const QPointF pixel = BrushRaster::pixelToDocument(placement, image.width(), image.height()).inverted().map(point);
+ return qGray(image.pixel(int(pixel.x()), int(pixel.y())));
+}
+}
+
+class MaskPaintAnywhereTests : public QObject {
+ Q_OBJECT
+private slots:
+ void theBrushGrowsAMaskPastItsLayer();
+ void newAreaStartsAsTheMasksBackground();
+ void gradientsAndFillsGrowAMaskToo();
+ void otherToolsStayWithinTheMask();
+ void aSolidPlacedMaskGetsAPixelPerDocumentPixel();
+ void aSnapshotAndACommitFillWithTheBackground();
+ void theCanvasDrawsAGrownStrokeInPlace();
+ void uncoveredCornersKeepTheBackground();
+ void aGrownPlacedMaskMapsItsTilesUnderEffects();
+};
+
+void MaskPaintAnywhereTests::theBrushGrowsAMaskPastItsLayer()
+{
+ const auto session = masked(true);
+ const QUuid id = session->activeLayerID().value();
+ session->beginBrush(QPointF(70, 30));
+ session->continueBrush(QPointF(80, 30));
+ // The stroke's grid reaches the canvas, not just the layer.
+ QVERIFY(session->brushStroke()->width >= 100);
+ session->finishBrush();
+ const LayerMask mask = layerWith(*session, id).mask.value();
+ // Grown, it keeps its place on the document, still linked.
+ QVERIFY(mask.placement);
+ QVERIFY(mask.isLinked);
+ QVERIFY(mask.asset.size().width() > 20);
+ QCOMPARE(maskAt(*session, QPointF(75, 30)), 0);
+ QCOMPARE(maskAt(*session, QPointF(30, 30)), 255);
+ // Untouched in a touched tile: the background, white.
+ QCOMPARE(maskAt(*session, QPointF(90, 50)), 255);
+ // A linked mask still moves with its layer.
+ const QPointF before = mask.placement.value().origin;
+ session->selectTool(NavigationTool::move);
+ session->nudgeLayer(5, 0);
+ QCOMPARE(layerWith(*session, id).mask.value().placement.value().origin, before + QPointF(5, 0));
+}
+
+void MaskPaintAnywhereTests::newAreaStartsAsTheMasksBackground()
+{
+ // Hidden everywhere: new area hides, only the paint shows.
+ const auto session = masked(false);
+ session->setMaskPaintWhite(true);
+ session->setBrushSettings(brush(6, 1, 1, 1, 1));
+ session->beginBrush(QPointF(70, 30));
+ session->continueBrush(QPointF(80, 30));
+ session->finishBrush();
+ QCOMPARE(maskAt(*session, QPointF(75, 30)), 255);
+ QCOMPARE(maskAt(*session, QPointF(90, 50)), 0);
+ QCOMPARE(maskAt(*session, QPointF(30, 30)), 0);
+}
+
+void MaskPaintAnywhereTests::gradientsAndFillsGrowAMaskToo()
+{
+ const auto gradient = masked(true);
+ gradient->selectTool(NavigationTool::gradient);
+ gradient->beginGradient(QPointF(0, 30));
+ gradient->moveGradient(std::nullopt, QPointF(100, 30));
+ bool done = false;
+ gradient->commitGradient([&] { done = true; });
+ QVERIFY(QTest::qWaitFor([&] { return done; }, 5000));
+ QVERIFY(gradient->activeLayer().value().mask.value().placement);
+ QVERIFY(gradient->activeLayer().value().mask.value().asset.size().width() >= 100);
+ // The Smear paints within the mask's own grid.
+ const auto fill = masked(true);
+ fill->applySelection(rectPath(QRectF(60, 10, 30, 30)), SelectionMode::replace, QStringLiteral("Select"));
+ fill->selectLayerTarget(fill->activeLayerID().value(), true);
+ bool filled = false;
+ fill->fillSelection(EditorSession::FillSource::foreground, [&] { filled = true; });
+ QVERIFY(QTest::qWaitFor([&] { return filled; }, 5000));
+ QCOMPARE(maskAt(*fill, QPointF(75, 25)), 0);
+ QCOMPARE(maskAt(*fill, QPointF(30, 30)), 255);
+}
+
+void MaskPaintAnywhereTests::otherToolsStayWithinTheMask()
+{
+ // The Smear's blur paints within the mask's own grid.
+ const auto session = masked(true);
+ session->selectTool(NavigationTool::blur);
+ session->setBlurMode(BlurToolMode::blur);
+ session->beginBrush(QPointF(30, 30));
+ QVERIFY(session->brushStroke() && session->brushStroke()->isBlur);
+ QCOMPARE(session->brushStroke()->width, 20);
+ session->cancelBrush();
+}
+
+void MaskPaintAnywhereTests::aSolidPlacedMaskGetsAPixelPerDocumentPixel()
+{
+ // Two-pixel masks are solid; this placement is 20 by 10.
+ const auto session = masked(true);
+ const QUuid id = session->activeLayerID().value();
+ rewrite(*session, [&](ProjectSnapshot &snapshot) {
+ ProjectLayerRecord &layer = record(snapshot, id);
+ QImage white = BrushRaster::context(2, 2, true);
+ white.fill(255);
+ snapshot.masks.insert_or_assign(id, ImportedImage(white, white, QStringLiteral("Mask")));
+ layer.maskPlacement = LayerTransform{.origin = QPointF(20, 20), .size = QSizeF(20, 10)};
+ layer.maskLinked = false;
+ });
+ session->selectLayerTarget(id, true);
+ QCOMPARE(layerWith(*session, id).mask.value().asset.size(), QSize(2, 2));
+ // Without growth the grid is the box, a pixel each.
+ session->selectTool(NavigationTool::blur);
+ session->setBlurMode(BlurToolMode::blur);
+ session->beginBrush(QPointF(25, 25));
+ QCOMPARE(session->brushStroke()->width, 20);
+ QCOMPARE(session->brushStroke()->height, 10);
+ session->cancelBrush();
+ // The brush grows it, still a pixel per document pixel.
+ session->selectTool(NavigationTool::brush);
+ session->beginBrush(QPointF(30, 25));
+ QCOMPARE(session->brushStroke()->width, 100);
+ QCOMPARE(session->brushStroke()->height, 60);
+ session->finishBrush();
+ QCOMPARE(maskAt(*session, QPointF(30, 25)), 0);
+ QCOMPARE(maskAt(*session, QPointF(22, 22)), 255);
+}
+
+void MaskPaintAnywhereTests::aSnapshotAndACommitFillWithTheBackground()
+{
+ // Past its base a mask snapshot is its fill.
+ QImage base = BrushRaster::context(4, 4, true);
+ base.fill(255);
+ const auto snapshot = RasterSnapshot::replacing(ImportedImage(base, base, QStringLiteral("Mask")), QRectF(2, 2, 4, 4), {}, QRectF(0, 0, 8, 8), true, 0);
+ const QImage image = snapshot->makeImage();
+ QCOMPARE(qGray(image.pixel(0, 0)), 0);
+ QCOMPARE(qGray(image.pixel(3, 3)), 255);
+ const BrushCommit::Output output = BrushCommit::render(BrushCommit::Input{8, 8, ImportedImage(base, base, QStringLiteral("Mask")), {}, true,
+ QStringLiteral("Mask"), QRectF(2, 2, 4, 4), 0});
+ QCOMPARE(qGray(output.asset.image().pixel(0, 0)), 0);
+ QCOMPARE(qGray(output.asset.image().pixel(3, 3)), 255);
+}
+
+void MaskPaintAnywhereTests::theCanvasDrawsAGrownStrokeInPlace()
+{
+ const auto session = masked(true);
+ CanvasView canvas(*session);
+ canvas.resize(100, 60);
+ session->viewport.resize(QSizeF(100, 60), 1, QSizeF(100, 60));
+ session->zoom(1);
+ session->beginBrush(QPointF(70, 30));
+ session->continueBrush(QPointF(80, 30));
+ const QImage shot = canvas.grab().toImage();
+ const auto at = [&](QPointF point) { return shot.pixelColor(session->viewport.viewPoint(point, QSizeF(100, 60)).toPoint()); };
+ // Mid-stroke the layer stays where it is, whole.
+ QCOMPARE(at(QPointF(21.5, 21.5)), QColor(Qt::blue));
+ QCOMPARE(at(QPointF(38.5, 38.5)), QColor(Qt::blue));
+ QVERIFY(at(QPointF(45.5, 30.5)) != QColor(Qt::blue));
+}
+
+void MaskPaintAnywhereTests::uncoveredCornersKeepTheBackground()
+{
+ // Tiles apart: the grown box holds corners no tile covers.
+ const auto brushed = masked(false, QSize(600, 400));
+ brushed->setMaskPaintWhite(true);
+ brushed->setBrushSettings(brush(6, 1, 1, 1, 1));
+ brushed->beginBrush(QPointF(500, 350));
+ brushed->continueBrush(QPointF(510, 350));
+ brushed->finishBrush();
+ QCOMPARE(maskAt(*brushed, QPointF(505, 350)), 255);
+ QCOMPARE(maskAt(*brushed, QPointF(300, 100)), 0);
+ // A fill, committed off the thread, keeps it too.
+ const auto filled = masked(false, QSize(600, 400));
+ filled->applySelection(rectPath(QRectF(500, 340, 20, 20)), SelectionMode::replace, QStringLiteral("Select"));
+ filled->selectLayerTarget(filled->activeLayerID().value(), true);
+ filled->setMaskPaintWhite(true);
+ bool done = false;
+ filled->fillSelection(EditorSession::FillSource::foreground, [&] { done = true; });
+ QVERIFY(QTest::qWaitFor([&] { return done; }, 5000));
+ QCOMPARE(maskAt(*filled, QPointF(510, 350)), 255);
+ QCOMPARE(maskAt(*filled, QPointF(300, 100)), 0);
+ // Revealing everywhere, the corner stays white.
+ const auto revealed = masked(true, QSize(600, 400));
+ revealed->applySelection(rectPath(QRectF(500, 340, 20, 20)), SelectionMode::replace, QStringLiteral("Select"));
+ revealed->selectLayerTarget(revealed->activeLayerID().value(), true);
+ done = false;
+ revealed->fillSelection(EditorSession::FillSource::foreground, [&] { done = true; });
+ QVERIFY(QTest::qWaitFor([&] { return done; }, 5000));
+ QCOMPARE(maskAt(*revealed, QPointF(510, 350)), 0);
+ QCOMPARE(maskAt(*revealed, QPointF(300, 100)), 255);
+}
+
+void MaskPaintAnywhereTests::aGrownPlacedMaskMapsItsTilesUnderEffects()
+{
+ // A wide layer; its solid placed mask grows wide.
+ auto session = std::make_unique();
+ session->createDocument(1200, 100, true);
+ QImage blue = BrushRaster::context(200, 40, false);
+ blue.fill(Qt::blue);
+ session->addPixelLayer(blue, QPointF(100, 30), QStringLiteral("Blue"), QStringLiteral("Add"));
+ const QUuid id = session->activeLayerID().value();
+ LayerEffects effects;
+ effects.stroke = StrokeEffect{.size = 2, .red = 1, .green = 1, .blue = 0, .opacity = 1};
+ session->setEffects(effects);
+ session->addLayerMask(true);
+ rewrite(*session, [&](ProjectSnapshot &snapshot) {
+ ProjectLayerRecord &layer = record(snapshot, id);
+ layer.maskPlacement = layer.transform;
+ layer.maskLinked = false;
+ });
+ session->selectLayerTarget(id, true);
+ session->selectTool(NavigationTool::brush);
+ session->setBrushSettings(brush(10, 1, 0, 0, 0));
+ CanvasView canvas(*session);
+ canvas.resize(1200, 100);
+ session->viewport.resize(QSizeF(1200, 100), 1, QSizeF(1200, 100));
+ session->zoom(1);
+ // A first pass at the left end, then the right.
+ session->beginBrush(QPointF(110, 50));
+ canvas.grab();
+ session->continueBrush(QPointF(110, 51));
+ canvas.grab();
+ session->continueBrush(QPointF(280, 50));
+ const QImage shot = canvas.grab().toImage();
+ const auto at = [&](QPointF point) { return shot.pixelColor(session->viewport.viewPoint(point, QSizeF(1200, 100)).toPoint()); };
+ QVERIFY2(at(QPointF(280.5, 50.5)) != QColor(Qt::blue), qPrintable(at(QPointF(280.5, 50.5)).name()));
+ QCOMPARE(at(QPointF(200.5, 35.5)), QColor(Qt::blue));
+}
+
+QTEST_MAIN(MaskPaintAnywhereTests)
+#include "MaskPaintAnywhereTests.moc"
diff --git a/tests/Tests.cmake b/tests/Tests.cmake
index f0ccb8c..c1f193a 100644
--- a/tests/Tests.cmake
+++ b/tests/Tests.cmake
@@ -200,6 +200,7 @@ compositor_test(EffectsPreviewCacheTests)
compositor_test(LayerEffectsSurfaceTests)
compositor_test(EffectsCanvasTests)
compositor_test(MaskEffectsCanvasTests)
+compositor_test(MaskPaintAnywhereTests)
compositor_test(EffectsFailureTests)
compositor_test(EffectsPanelTests)
compositor_test(LayerEffectRowTests)