From 3d56688ce40ca7eba18da851a5ec7a5deb022b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 23:24:35 -0400 Subject: [PATCH] Magic tool Object mode (Compositor 1.1.8) --- AGENTS.md | 1 + CMakeLists.txt | 3 + TASKS.md | 5 +- src/ContentView+Status.cpp | 6 +- src/Document/EditorSession+Jobs.h | 3 +- src/Document/EditorSession+Model.cpp | 2 +- src/Document/EditorSession.cpp | 2 + src/Document/EditorSession.h | 4 + src/Document/MagicWand+Session.h | 4 +- src/Document/MagicWand.cpp | 16 +- src/Document/ObjectSelection+Session.h | 8 + src/Document/ObjectSelection.cpp | 272 +++++++++++++ src/Document/ObjectSelection.h | 26 ++ src/Document/Selection+Modify.cpp | 83 ++++ src/Document/Selection+Session.h | 1 + src/Document/Selection.cpp | 91 +---- src/Document/Selection.h | 6 + src/Document/SubjectRemoval.cpp | 5 + src/Document/SubjectRemoval.h | 2 + src/Rendering/EditorCanvas+Keys.cpp | 2 - src/Rendering/EditorCanvas+Selection.cpp | 21 +- src/Rendering/SelectionIcons.cpp | 13 + src/Rendering/SelectionIcons.h | 2 +- src/UI/KeyboardShortcuts.cpp | 2 +- src/UI/LassoControls.cpp | 78 +++- src/UI/LassoControls.h | 11 + src/UI/NativeLayerList.cpp | 6 +- src/UI/ToolIcons.cpp | 2 +- src/UI/ToolIcons.h | 2 +- tests/CompositorUITestsLaunchTests.cpp | 2 +- tests/ContentViewStatusTests.cpp | 8 +- tests/ContentViewTests.cpp | 11 +- tests/LassoControlsTests.cpp | 99 ++++- tests/ObjectSelectionTests.cpp | 475 +++++++++++++++++++++++ tests/SessionRecord.h | 2 + tests/SubjectRemovalFailureTests.cpp | 19 + tests/SubjectRemovalModelTests.cpp | 19 + tests/ToolIconTests.cpp | 13 +- tests/fixtures/ToolIcons.png | Bin 20050 -> 21162 bytes 39 files changed, 1193 insertions(+), 134 deletions(-) create mode 100644 src/Document/ObjectSelection+Session.h create mode 100644 src/Document/ObjectSelection.cpp create mode 100644 src/Document/ObjectSelection.h create mode 100644 src/Document/Selection+Modify.cpp create mode 100644 tests/ObjectSelectionTests.cpp diff --git a/AGENTS.md b/AGENTS.md index 7e78958..e99fffe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,7 @@ Platform adaptations, each forced by a missing Qt analog: - Keyboard shortcuts (12.2b, Swift 1.1.6): `UI/KeyboardShortcuts.h/.cpp` twin Swift's file, `KeyboardShortcuts+Sheet.cpp` its sheet and recorder. `ShortcutChord` keeps Swift's key strings (Backspace 0x7f, Return, Escape, Tab, Space, the arrows as Cocoa's F700s) and modifier bits, Ctrl for Command, Alt for Option, Meta for Control, then Shift; a key event reads Backspace and Delete alike, Enter as Return, Backtab as Tab, a shifted bracket or sign as its own key, anything else by its lowered key name, so a function key's name is long and refused (Swift takes it as a private-use character; known difference); labels are `QKeySequence`'s native text (Ctrl+Alt+K), where Swift prints symbols. `ShortcutDefinition::all` is Swift's list less Hide Compositor, which has no twin; `problem` keeps Swift's rules less its three chords reserved by macOS (the desktop takes its own before the app does). `ShortcutSettings` keeps the overrides in `QSettings` under Swift's `keyboardShortcuts.v1`, as Swift's JSON, a stored set that fails `problem` ignored with a warning on `compositor.app` where Swift is silent; `changed()` stands for its observation, and `reload` reads the store again, as a launch does. The menus remember each entry's own key in a property and take `menu` of it on every change. `canvasEvent` and `textEvent` hand back the press to handle, or none to swallow: the canvas (its key handling moved to `EditorCanvas+Keys.cpp`, the navigation file at 500 lines), the layer list and the window's key filter read `canvasEvent`, open text `textEvent`; a translated press the canvas or list leaves passes the original on, as `super.keyDown` does. Swift's `panPhysicalKey` is `m_panKey`: the key whose release ends a remapped pan. `NativeShortcut` is `configuredNativeShortcut`: a `QShortcut` over the sheet and its children clicks its button when that shows (a disabled button refuses `click` by itself); its own plain Return or Escape rests it, leaving the dialogs' default and escape handling, so a field's Return still commits first, while a button moved onto the other's key takes it; a moved original key is swallowed at the sheet; a synthesized press carries Swift's shifted text (`}` for Shift+]), which the brush keys read; `NewCanvasSheet`'s own Return shortcuts rest while Apply is moved; Levels' Preview takes its chord this way, in place of the checkbox's shortcut. The sheet is Swift's: 660 wide, the hint, a search, the three groups of rows with 150-by-26 recorders, the contextual words in Linux's keys, the problem in `BrightText` at 12 pixels only while there is one, Restore Defaults, Cancel and a default Save resting while recording or wrong; a modifier alone keeps recording, and while recording the recorder claims every key, Swift's `performKeyEquivalent`: a `Qt::Tool` panel keeps its parent window's menu shortcuts live, and `QWidget::event` would move the focus on Tab. It opens from the Edit menu, after Paste and a separator, in the `keyboardShortcuts` panel `CompositorMenus` holds. Equivalent mutants: `menu`'s and the Shift branch's `isMenu` terms (menu chords all carry modifiers), `canvasEvent`'s early return without overrides, `m_panKey`'s reset (every pan sets it) and Save's explicit default (its only auto-default button). Tests: `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests`, `RemappedKeysTests`. - Guides and the layout grid (12.3a, Swift 1.1.7): `Document/Guides.h/.cpp` twin Swift's `Guides.swift`: `CanvasGuide` (its axis by Swift's raw values, `rawValue` and `guideAxis`), `LayoutGrid` (a namespace), `GuideDrag`, and the session extension, declared in `Guides+Session.h` with the accessors of Swift's view flags (`showsGrid`, `showsGuides`, `showsRulers`, `snapEnabled`, the four Snap To flags, `locksGuides`, each a getter and a setter that notifies, and `guideDrag`), whose state stays in `EditorSession.h`; `guideHitDistance` and `guideColor` are Swift's statics. `finishGuideDrag` takes `removing` where Swift names it `delete`. `canEditGuides` leaves out Swift's `_ = showsBusy`, which only registers an observation. 1.1.7's `snapEnabled` (Snap To's master switch, read by `alignmentSnapTargets` and `snappedGuidePosition`) stands beside 1.1.6's `snappingEnabled` (read by `snappedMove` and the crop drag), as it does in Swift through 1.3.3, with a View entry each. `transformSnapTargets` and `cropSnapTargets` are `alignmentSnapTargets` with and without centres, the canvas's middle after its edges and a layer's between its edges, Swift's order; `TransformSnap::box` (in `Crop.cpp`) is the upright box both files read. The document holds `guides`, equality and undo included; the manifest's optional `guides` is Swift's `Codable` (an array of `id`, `axis` and `position`, left out when there are none, the snapshot writing none for an empty list), and the store refuses guides before version 8 (invalid), more than 1,000 (too large), a repeated id or a position past a million (invalid). `installProject` and `clearProject` drop a drag; `CanvasDocument(snapshot)` loads the guides, so `applyDocumentSize` and `applyImageSize` keep them; the canvas resizer offsets them, the image resizer scales them (a same-size resize keeps them), the canvas flip mirrors those across it. The View menu gains Swift's Show (Grid Ctrl+', Guides Ctrl+;), Rulers (Ctrl+R), Snap (Ctrl+Shift+;), Snap To (Guides, Grid, Layers, Document Bounds), Lock Guides (Ctrl+Alt+;) and Clear Guides, resting without a document or, for Clear, without guides; the five new shortcut definitions join the list. Equivalent mutants: `finishGuideDrag`'s `isNew` test on removal and its `original != position` test (the history drops a step that changes nothing). The rulers, the grid and guides on the canvas and their drags are 12.3b's. Tests: `GuideTests` (Swift's but the canvas drag and the ruler's steps), `ViewMenuTests`, the targets' order in `TransformSnapTests`. - Rulers, and guides on the canvas (12.3b, Swift 1.1.7): `UI/CanvasRulers.h/.cpp` twin Swift's file: `CanvasRuler::thickness`, `CanvasRulerCorner` and `CanvasRulerView` (a widget per strip, repainting on every `changed()`, where Swift's canvas asks each ruler to redraw), with Swift's `majorStep` and `label`. The canvas is the rulers' sibling, so a ruler maps its points through the screen (`inCanvas`), where Swift reads its own bounds as the canvas's. A press hands the canvas the keys and begins a guide from that ruler (horizontal from the top one), a drag moves it, a release over a ruler drops it; a ruler has no mouse tracking, so it sees no buttonless move and leaves a lost release to the canvas's next press. `ContentView` frames the canvas slot in a grid with the corner and the two strips, shown while `showsRulers` holds and a document is open. `TransformOverlay` draws the layout grid (majors solid gray 0.7 at 45%, subdivisions dotted one point on and two off in gray 0.55 at 28% once four points apart, both a hairline) and the guides (`EditorSession::guideColor`, a hairline across the whole view) first, as Swift's `draw`; the canvas redraws the view when the grid's flag, the guides' flag or the displayed guides change (`GuidesShown`), Swift's observation. Guide drags live in `EditorCanvas+Guides.cpp`: with the Move tool a press on a guide (`canEditGuides` and `hitGuide`) moves it ahead of any layer under the resize cursor of its axis, a drag follows, a release over a ruler (`isOverRuler`: a point left of or above the canvas while the rulers show) deletes it; Escape cancels it ahead of a transform edit's keys; wheels and pinches rest while one is dragged; the Move tool's hover shows the guide's resize cursor after the handles and before a layer's move cursor. Qt can lose a release: a buttonless move and a lost focus finish the drag where it is. Equivalent mutant: the ruler press's `canEditGuides` test (`beginGuideCreation` asks it again). Tests: `GuideCanvasTests` (Swift's `moveToolDragsAGuideAndRulerDropDeletesIt` and `rulerStepUsesNicePixelIntervals`; the drawing, repaints, cursors, keys, lost releases, the rulers' layout, ticks, repaints, focus and drags), the canvas's place in `ContentViewTests`. +- The Magic tool's Object mode (12.4, Swift 1.1.8): `Document/ObjectSelection.h/.cpp` twin Swift's file, `ObjectSelection+Session.h` its session half; `WandMode` (Swift's raw values, `allWandModes`) sits in `Selection.h`. Vision's foreground instances have no Linux twin (the user's decision): `SubjectRemoval::foreground` hands back U²-Net's mask at the image's size (the Remove Background model and cache, 9.7), which `select` scales to the model's 320 grid, cuts at half, and splits into 8-connected regions (`region`, the neighbours a traced outline joins); the region under the clicked cell is the instance, background none, and a picture where the model finds no subject none, as Vision's empty result. Its cells are enlarged smoothly to the image and refined by `GuidedMatte::refine` (radius 5) on the image, for Swift's edge-preserving upsample, then cut at half; `adjusted` erodes or dilates a pixel ring a step (eight neighbours, ten steps at most, the image's edge counting as inside, Swift's `eroded` and `dilated`), `MagicWand::outline` traces it and `smoothed` rounds the stairs as Swift's (the start Qt's close repeats dropped first, since a CGPath's close adds no point, then a subpath under three points dropped, Douglas–Peucker at 1.6 from the leftmost point, Chaikin three times, the fill rule kept; Swift's later drops of a repeated end and Chaikin's guard under three points are dead after that and left out). A `bad_alloc` is `ExportError(render)`; Swift's `unsupported` has no twin. The worker catches `SubjectRemovalError`, `MagicWandError` (the trace's refusals) and `ExportError`, each landing in `brushError` as Swift's `catch`. `selectionSample` is the wand's sample made public as Swift's; `selectObject` runs through the wand's watcher and landing (`Wanding` carries the step's name, `Object Selection`), with a completion callback, the offset clamped to ten either way and the anti-alias setting as its smoothing. `pressWandKey` chooses the tool, W on the canvas and in the list, a repeat ignored; Tab steps the mode; the tool's label, its shortcut definition (`Magic`) and the bar's title say Magic. The bar (`LassoControls`) shows Wand and Object as checkable buttons for Magic alone, the colour wand's controls in Wand mode, and in Object mode This Layer and All Layers and an Edge field of −10 to 10 px (`SelectionAmountField` takes a sign when its range goes below zero; a lone minus changes nothing, an overflow reads as its end), with Swift's help; the anti-alias help follows the mode. The canvas's press runs `selectObject` ahead of the colour wand, a click inside the selection picks afresh, the cursor is the crosshair with `SelectionIcon::objectSelection` (haloed by eight offsets as every selection icon, where Swift strokes it white), and the rail draws the icon for Object mode (`ToolIconKind::object`); the status hint follows the mode. Known difference: U²-Net finds one salient mask, so objects touching in it are one region where Vision may tell instances apart. Left out as dead: the mode picker's `cancelLasso` (Magic never holds a draft: `beginLasso` refuses it and `selectTool` cancels one), `selectObject`'s busy term (`canEditSelection` reads it) and its clamp of the offset (`adjusted` clamps), and a null check of the enlarged cells (`GuidedMatte::refine` checks its own). Equivalent mutants: the grid's cut at exactly 128, `simplifyClosed`'s guard under four points (the fallback returns the same points), `finish`'s three-point threshold at four (a traced loop has four corners), the Chaikin reserve, the edge's rounding (typing and arrows give whole numbers), W calling `selectTool` (`pressWandKey` is that call). Untested failure paths: the region's own 320-cell image, 100 KB, which no limit starves without starving the grid before it, and the worker's `MagicWandError`: a refined mask never nears eight million edges, and the trace's image-sized buffer asks less than the guided filter before it. Tests: `ObjectSelectionTests` (regions, edges, smoothing, the model on red discs, the session, keys, the canvas), the bar in `LassoControlsTests`, the icon sheet, the rail and hints in `ContentViewTests` and `ContentViewStatusTests`. - 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. - `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. diff --git a/CMakeLists.txt b/CMakeLists.txt index e45f82a..cf48fd6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,7 @@ add_library(compositor_core STATIC src/Document/SelectionClipboard.cpp src/Document/Crop.cpp src/Document/Guides.cpp + src/Document/ObjectSelection.cpp src/Document/Filters.cpp src/Document/GuidedMatte.cpp src/Document/SubjectRemoval.cpp @@ -72,6 +73,7 @@ add_library(compositor_core STATIC src/Document/MaskTracing.cpp src/Document/PixelInvert.cpp src/Document/Selection.cpp + src/Document/Selection+Modify.cpp src/Document/SelectionEdits.cpp src/Document/ShapeTool.cpp src/Document/TypeTool.cpp @@ -440,6 +442,7 @@ compositor_test(SubjectRemovalModelTests) compositor_test(SubjectRemovalFailureTests) compositor_test(RemoveBackgroundTests) compositor_test(SelectSubjectTests) +compositor_test(ObjectSelectionTests) compositor_test(LayerEffectsRendererTests) compositor_test(LayerFlipTests) compositor_test(LayerMergeTests) diff --git a/TASKS.md b/TASKS.md index 7ce3831..0fe257d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -317,7 +317,10 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - 12.3b work: the rulers beside the canvas (corner, strips, ticks and labels, drags that make and drop guides), the grid and guides drawn over the canvas, guide drags with the Move tool (cursor, Escape, rests for wheels and pinches, lost releases). Evidence: every test green in the dev image, lint clean; mutants over the rulers, the overlay, the canvas's drags, keys, cursor and repaint, and the layout: all caught but the equivalent one AGENTS.md names. - 12.3b round 1: FAIL, three findings. Six drawn details passed their tests broken: the ruler's half-step tick, its labels' place, presence and gray, the guides' hairline width, the grid majors' ink, and Escape taking a guide drag ahead of an open transform edit (each pinned now, its mutant caught); a screenshot two tests saved in the working directory (they save beside the test binary now, the stray file removed); a no-op assertion (gone). - 12.3b round 2: PASS, no findings. -- [ ] 12.4 Version 1.1.8: the Magic tool's Object mode (U²-Net and the clicked region). +- [x] 12.4 Version 1.1.8: the Magic tool's Object mode (U²-Net and the clicked region). +- 12.4 work: Object mode (U²-Net regions on the model grid, the guided edge, edge steps, smoothing), the session call, W and Tab, the Magic bar (modes, source, a signed Edge field), the canvas press and cursor, the rail icon and hints. Evidence: every test green in the dev image, lint clean; statement deletion over `ObjectSelection.cpp` and targeted mutants over the model, session, bar, canvas, list, hints, wand landing and icons: all caught but the equivalents AGENTS.md names. +- 12.4 round 1: FAIL, five findings. A trace the wand kernel refuses left the Object worker uncaught and aborted the app (the worker catches `MagicWandError` now; no test reaches it, named in AGENTS.md); three details passed their tests broken: the press handing the view point on (the click now lands through a zoom), the cell row read through the width (a disc low on a 4:3 picture now), the bounds rounding where Swift floors (a click in the last half pixel now), and a minus after a digit (typed now); the icon sheet saved in the working directory (beside the binary now, the stray file gone); Qt's repeated start counted toward three points (dropped first now, Swift's later drops and Chaikin's guard dead and gone); the signal record lacked the mode and settings (read now). +- 12.4 round 2: PASS. Its residuals: the three-point threshold named equivalent in AGENTS.md; the launch test's screenshot now saved beside its binary, the same cause as the icon sheet. - [ ] 12.5a Version 1.2.0, part one: Photoshop PSD import (the reader, channel coder, vectors, document builder, the conversion report sheet). - [ ] 12.5b Version 1.2.0, part two: the Outer Glow effect, brush Smoothing, and the release's other changes to what the twin holds. - [ ] 12.6 Version 1.2.1: camera RAW import through LibRaw with its develop step, middle-button pan, persistent tool settings, Auto Select picking the foreground layer, the reshaped app icon (`45ab8fd`) in `packaging/icons`. diff --git a/src/ContentView+Status.cpp b/src/ContentView+Status.cpp index 8c7028b..d302708 100644 --- a/src/ContentView+Status.cpp +++ b/src/ContentView+Status.cpp @@ -20,6 +20,8 @@ ToolIconKind ContentView::iconKind(NavigationTool tool) const return ToolIconKind::polygonal; if (tool == NavigationTool::brush && m_session.brushMode() == BrushToolMode::erase) return ToolIconKind::eraser; + if (tool == NavigationTool::wand && m_session.wandMode() == WandMode::object) + return ToolIconKind::object; return ToolIconKind::plain; } @@ -29,6 +31,8 @@ QString ContentView::hint(NavigationTool tool, ToolIconKind kind, BlurToolMode s return QStringLiteral("Drag an ellipse · Shift add · Alt subtract · Shift again mid-drag circle · Drag inside to move · Delete clears · Ctrl+D deselect"); if (tool == NavigationTool::lasso && kind == ToolIconKind::polygonal) return QStringLiteral("Click corners · Click start, double-click or Enter to close · Delete removes corner · Escape cancel"); + if (tool == NavigationTool::wand && kind == ToolIconKind::object) + return QStringLiteral("Click an object to select its outline · Tab for Wand · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"); if (tool == NavigationTool::brush && kind == ToolIconKind::eraser) return QStringLiteral("Drag to erase · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan"); if (tool == NavigationTool::blur) @@ -42,7 +46,7 @@ QString ContentView::hint(NavigationTool tool, ToolIconKind kind, BlurToolMode s // Swift's last branch: the Eyedropper shows Zoom's words too. static const std::map<NavigationTool, const char *> hints = { {NavigationTool::marquee, "Drag a rectangle · Shift add · Alt subtract · Shift again mid-drag square · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"}, - {NavigationTool::wand, "Click to select similar colors · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"}, + {NavigationTool::wand, "Click to select similar colors · Tab for Object · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"}, {NavigationTool::lasso, "Drag to select · Drag inside to move · Shift add · Alt subtract · Delete clears · Alt+Backspace/Ctrl+Backspace fill · Ctrl+D deselect"}, {NavigationTool::brush, "Drag to paint · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan"}, {NavigationTool::cloneStamp, "Alt-click to set the source · Drag to clone · [ ] size · Shift-[ ] hardness · 1–0 opacity · Space to pan"}, diff --git a/src/Document/EditorSession+Jobs.h b/src/Document/EditorSession+Jobs.h index 06b7b92..0a623d9 100644 --- a/src/Document/EditorSession+Jobs.h +++ b/src/Document/EditorSession+Jobs.h @@ -46,11 +46,12 @@ struct SessionJobs { std::optional<QImage> image; std::optional<QString> failure; }; - // What a wand click waits for and applies. + // The Magic tool's job, either mode, and its step's name. struct Wanding { SelectionMode mode; QUuid documentID; std::function<void()> done; + QString name; }; struct Wanded { std::optional<QPainterPath> path; diff --git a/src/Document/EditorSession+Model.cpp b/src/Document/EditorSession+Model.cpp index 20f06c0..df61ccb 100644 --- a/src/Document/EditorSession+Model.cpp +++ b/src/Document/EditorSession+Model.cpp @@ -96,7 +96,7 @@ QString label(NavigationTool tool) case NavigationTool::move: return QStringLiteral("Move / Transform (V)"); case NavigationTool::marquee: return QStringLiteral("Marquee (M)"); case NavigationTool::lasso: return QStringLiteral("Lasso (L)"); - case NavigationTool::wand: return QStringLiteral("Magic Wand (W)"); + case NavigationTool::wand: return QStringLiteral("Magic (W) · Tab switches Wand and Object"); case NavigationTool::crop: return QStringLiteral("Crop (C)"); case NavigationTool::brush: return QStringLiteral("Brush (B) · Eraser (E)"); case NavigationTool::spotHealing: return QStringLiteral("Spot Healing Brush (J)"); diff --git a/src/Document/EditorSession.cpp b/src/Document/EditorSession.cpp index 838355b..052fc38 100644 --- a/src/Document/EditorSession.cpp +++ b/src/Document/EditorSession.cpp @@ -165,6 +165,8 @@ void EditorSession::cycleToolMode() return; if (m_tool == NavigationTool::marquee) toggleMarqueeKind(); + else if (m_tool == NavigationTool::wand) + setWandMode(allWandModes[(size_t(m_wandMode) + 1) % allWandModes.size()]); else if (m_tool == NavigationTool::lasso) toggleLassoKind(); else if (m_tool == NavigationTool::shape) diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index 7f1f69f..331da10 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -8,6 +8,7 @@ #include "Document/LevelsAutomatic.h" #include "Document/FloatingSelection.h" #include "Document/MagicWand.h" +#include "Document/ObjectSelection.h" #include "Document/SelectionClipboard.h" #include "Document/SelectionEdits.h" #include "Document/SmudgeLiquify.h" @@ -298,6 +299,7 @@ public: #include "Document/AdjustmentEditing+Session.h" #include "Document/LayerEffects+Session.h" #include "Document/Guides+Session.h" +#include "Document/ObjectSelection+Session.h" signals: void changed(); @@ -376,6 +378,8 @@ private: std::optional<Inverting> m_inverting; QFutureWatcher<Inverted> m_inverter; WandSettings m_wandSettings; + WandMode m_wandMode = WandMode::wand; + ObjectSelectionSettings m_objectSelectionSettings; std::optional<Wanding> m_wanding; QFutureWatcher<Wanded> m_wand; std::optional<Subjecting> m_subjecting; diff --git a/src/Document/MagicWand+Session.h b/src/Document/MagicWand+Session.h index 613f33a..d714456 100644 --- a/src/Document/MagicWand+Session.h +++ b/src/Document/MagicWand+Session.h @@ -3,6 +3,8 @@ public: // Off the UI thread; `done` follows the step or refusal. void magicWand(QPointF point, SelectionMode mode, std::function<void()> done = {}); + // What selection-from-image tools read, at document size. + std::optional<QImage> selectionSample(const CanvasDocument &document, bool sampleAllLayers) const; + private: void finishWand(); - std::optional<QImage> wandSample(const CanvasDocument &document) const; diff --git a/src/Document/MagicWand.cpp b/src/Document/MagicWand.cpp index 70925c8..a5b0733 100644 --- a/src/Document/MagicWand.cpp +++ b/src/Document/MagicWand.cpp @@ -97,13 +97,13 @@ void EditorSession::setWandSettings(const WandSettings &settings) } // What the wand reads: composite, or the active layer's pixels. -std::optional<QImage> EditorSession::wandSample(const CanvasDocument &document) const +std::optional<QImage> EditorSession::selectionSample(const CanvasDocument &document, bool sampleAllLayers) const { try { QImage context = BrushRaster::context(document.width, document.height, false); QPainter painter(&context); // A painted asset flattens here; the catch covers it too. - if (m_wandSettings.sampleAllLayers) { + if (sampleAllLayers) { drawLiveComposite(document, painter); } else if (const std::optional<ImageLayer> layer = activeLayer(); layer && !layer->isGroup && layer->asset) { const LayerTransform transform = displayedTransform(*layer); @@ -112,7 +112,7 @@ std::optional<QImage> EditorSession::wandSample(const CanvasDocument &document) painter.end(); return context; } catch (const ExportError &error) { - qCWarning(lcApp) << "the wand cannot sample the canvas:" << error.what(); + qCWarning(lcApp) << "a selection tool cannot sample the canvas:" << error.what(); return std::nullopt; } } @@ -131,13 +131,13 @@ void EditorSession::magicWand(QPointF point, SelectionMode mode, std::function<v finish(); return; } - const std::optional<QImage> sample = wandSample(*document); + const std::optional<QImage> sample = selectionSample(*document, m_wandSettings.sampleAllLayers); if (!sample) { finish(); return; } setIsProjectBusy(true); - m_wanding = Wanding{mode, document->id, std::move(done)}; + m_wanding = Wanding{mode, document->id, std::move(done), QStringLiteral("Magic Wand")}; m_wand.setFuture(QtConcurrent::run([sample = *sample, point, settings = m_wandSettings]() -> Wanded { try { return Wanded{MagicWand::select(sample, point, settings), std::nullopt}; @@ -155,7 +155,7 @@ void EditorSession::finishWand() const Wanding pending = std::exchange(m_wanding, std::nullopt).value(); setIsProjectBusy(false); if (result.failure) { - qCWarning(lcApp).noquote() << "the wand could not select:" << *result.failure; + qCWarning(lcApp).noquote() << pending.name << "could not select:" << *result.failure; setBrushError(result.failure); } else if (m_document && m_document->id == pending.documentID) { if (!result.path) { @@ -164,9 +164,9 @@ void EditorSession::finishWand() deselect(); } else if (pending.mode == SelectionMode::replace) { // A traced outline lies on the canvas: no costly clip. - setSelection(DocumentSelection{*result.path, m_selectionAntialiased}, QStringLiteral("Magic Wand")); + setSelection(DocumentSelection{*result.path, m_selectionAntialiased}, pending.name); } else { - applySelection(*result.path, pending.mode, QStringLiteral("Magic Wand")); + applySelection(*result.path, pending.mode, pending.name); } } if (pending.done) diff --git a/src/Document/ObjectSelection+Session.h b/src/Document/ObjectSelection+Session.h new file mode 100644 index 0000000..a2e83ca --- /dev/null +++ b/src/Document/ObjectSelection+Session.h @@ -0,0 +1,8 @@ +// Swift's ObjectSelection extension, and the Magic tool's mode. +public: + WandMode wandMode() const { return m_wandMode; } + void setWandMode(WandMode mode); + const ObjectSelectionSettings &objectSelectionSettings() const { return m_objectSelectionSettings; } + void setObjectSelectionSettings(const ObjectSelectionSettings &settings); + // Off the UI thread; `done` follows the step or refusal. + void selectObject(QPointF point, SelectionMode mode, std::function<void()> done = {}); diff --git a/src/Document/ObjectSelection.cpp b/src/Document/ObjectSelection.cpp new file mode 100644 index 0000000..9752404 --- /dev/null +++ b/src/Document/ObjectSelection.cpp @@ -0,0 +1,272 @@ +#include "Document/ObjectSelection.h" +#include "Document/GuidedMatte.h" +#include "Document/EditorSession.h" +#include "Document/MagicWand.h" +#include "Document/SubjectRemoval.h" +#include "IO/ImageExporter.h" +#include <algorithm> +#include <cmath> +#include <deque> +#include <QtConcurrent> +#include <limits> + +namespace { +double perpendicularDistance(QPointF point, QPointF a, QPointF b) +{ + const double dx = b.x() - a.x(), dy = b.y() - a.y(); + const double length = std::hypot(dx, dy); + if (!(length > 0)) + return std::hypot(point.x() - a.x(), point.y() - a.y()); + return std::abs(dy * point.x() - dx * point.y() + b.x() * a.y() - b.y() * a.x()) / length; +} + +// Swift's Douglas–Peucker on an open polyline. +std::vector<QPointF> simplifyOpen(const std::vector<QPointF> &points, size_t first, size_t last, double tolerance) +{ + if (last <= first + 1) + return {points[first], points[last]}; + size_t farthest = first + 1; + double greatest = 0; + for (size_t index = first + 1; index < last; ++index) { + const double distance = perpendicularDistance(points[index], points[first], points[last]); + if (distance > greatest) { + greatest = distance; + farthest = index; + } + } + if (!(greatest > tolerance)) + return {points[first], points[last]}; + std::vector<QPointF> left = simplifyOpen(points, first, farthest, tolerance); + const std::vector<QPointF> right = simplifyOpen(points, farthest, last, tolerance); + left.pop_back(); + left.insert(left.end(), right.begin(), right.end()); + return left; +} + +std::vector<QPointF> simplifyClosed(const std::vector<QPointF> &points, double tolerance) +{ + if (points.size() < 4) + return points; + // Break at a stable extreme so the whole contour survives. + const size_t start = size_t(std::min_element(points.begin(), points.end(), [](QPointF lhs, QPointF rhs) { + return lhs.x() == rhs.x() ? lhs.y() < rhs.y() : lhs.x() < rhs.x(); + }) - points.begin()); + std::vector<QPointF> open(points.begin() + std::ptrdiff_t(start), points.end()); + open.insert(open.end(), points.begin(), points.begin() + std::ptrdiff_t(start)); + open.push_back(open.front()); + open = simplifyOpen(open, 0, open.size() - 1, tolerance); + // Its ends are the same point. + open.pop_back(); + return open.size() >= 3 ? open : points; +} + +std::vector<QPointF> chaikin(std::vector<QPointF> points, int iterations) +{ + for (int round = 0; round < iterations; ++round) { + std::vector<QPointF> next; + next.reserve(points.size() * 2); + for (size_t index = 0; index < points.size(); ++index) { + const QPointF a = points[index], b = points[(index + 1) % points.size()]; + next.push_back(a * 0.75 + b * 0.25); + next.push_back(a * 0.25 + b * 0.75); + } + points = std::move(next); + } + return points; +} + +bool any(const std::vector<uchar> &mask, int width, int height, int x, int y, bool set) +{ + for (int ny = std::max(0, y - 1); ny <= std::min(height - 1, y + 1); ++ny) { + for (int nx = std::max(0, x - 1); nx <= std::min(width - 1, x + 1); ++nx) { + if ((mask[size_t(ny) * size_t(width) + size_t(nx)] != 0) == set) + return true; + } + } + return false; +} + +std::optional<QPainterPath> traced(const QImage &image, QPointF point, int edgeOffset, bool smoothEdges) +{ + const int width = image.width(), height = image.height(); + const int x = int(std::floor(point.x())), y = int(std::floor(point.y())); + if (!std::isfinite(point.x()) || !std::isfinite(point.y()) || x < 0 || x >= width || y < 0 || y >= height) + return std::nullopt; + QImage foreground; + try { + foreground = SubjectRemoval::foreground(image); + } catch (const SubjectRemovalError &error) { + // No subject anywhere: Vision finds no instance. + if (error.kind == SubjectRemovalError::Kind::noSubject) + return std::nullopt; + throw; + } + using ObjectSelection::grid; + // Regions on the model's grid stand for Vision's instances. + const QImage cells = foreground.scaled(grid, grid, Qt::IgnoreAspectRatio, Qt::SmoothTransformation).convertToFormat(QImage::Format_Grayscale8); + if (cells.isNull()) + throw ExportError(ExportError::Kind::render); + std::vector<uchar> binary(size_t(grid) * grid); + for (int row = 0; row < grid; ++row) { + for (int column = 0; column < grid; ++column) + binary[size_t(row) * grid + size_t(column)] = cells.constScanLine(row)[column] >= 128; + } + const int gx = std::clamp(int(point.x() / width * grid), 0, grid - 1), gy = std::clamp(int(point.y() / height * grid), 0, grid - 1); + const std::optional<std::vector<uchar>> coarse = ObjectSelection::region(binary, grid, grid, gx, gy); + if (!coarse) + return std::nullopt; + QImage small(grid, grid, QImage::Format_Grayscale8); + if (small.isNull()) + throw ExportError(ExportError::Kind::render); + for (int row = 0; row < grid; ++row) { + for (int column = 0; column < grid; ++column) + small.scanLine(row)[column] = (*coarse)[size_t(row) * grid + size_t(column)] ? 255 : 0; + } + // Swift's edge-preserving upsampling: the image guides the edge. + const QImage up = small.scaled(width, height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + const QImage refined = GuidedMatte::refine(up, image, 5, std::numeric_limits<double>::max()); + std::vector<uchar> mask(size_t(width) * size_t(height)); + for (int row = 0; row < height; ++row) { + const uchar *line = refined.constScanLine(row); + for (int column = 0; column < width; ++column) + mask[size_t(row) * size_t(width) + size_t(column)] = line[column] >= 128 ? 255 : 0; + } + mask = ObjectSelection::adjusted(std::move(mask), width, height, edgeOffset); + const std::optional<QPainterPath> outline = MagicWand::outline(mask, width, height); + if (!outline) + return std::nullopt; + return smoothEdges ? ObjectSelection::smoothed(*outline) : *outline; +} +} + +std::optional<QPainterPath> ObjectSelection::select(const QImage &image, QPointF point, int edgeOffset, bool smoothEdges) +{ + try { + return traced(image, point, edgeOffset, smoothEdges); + } catch (const std::bad_alloc &) { + // Masks the size of the image found no memory. + throw ExportError(ExportError::Kind::render); + } +} + +std::optional<std::vector<uchar>> ObjectSelection::region(const std::vector<uchar> &mask, int width, int height, int x, int y) +{ + const auto at = [width](int column, int row) { return size_t(row) * size_t(width) + size_t(column); }; + if (!mask[at(x, y)]) + return std::nullopt; + std::vector<uchar> result(mask.size()); + std::deque<std::pair<int, int>> pending{{x, y}}; + // Eight neighbours join a region, the cell itself included. + while (!pending.empty()) { + const auto [column, row] = pending.front(); + pending.pop_front(); + for (int ny = std::max(0, row - 1); ny <= std::min(height - 1, row + 1); ++ny) { + for (int nx = std::max(0, column - 1); nx <= std::min(width - 1, column + 1); ++nx) { + if (mask[at(nx, ny)] && !result[at(nx, ny)]) { + result[at(nx, ny)] = 1; + pending.emplace_back(nx, ny); + } + } + } + } + return result; +} + +std::vector<uchar> ObjectSelection::adjusted(std::vector<uchar> mask, int width, int height, int edgeOffset) +{ + const int steps = std::min(10, std::abs(edgeOffset)); + for (int step = 0; step < steps; ++step) { + std::vector<uchar> result = mask; + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const size_t index = size_t(y) * size_t(width) + size_t(x); + if (edgeOffset > 0 && mask[index]) + result[index] = any(mask, width, height, x, y, false) ? 0 : 255; + else if (edgeOffset < 0 && !mask[index] && any(mask, width, height, x, y, true)) + result[index] = 255; + } + } + mask = std::move(result); + } + return mask; +} + +QPainterPath ObjectSelection::smoothed(const QPainterPath &path) +{ + std::vector<std::vector<QPointF>> subpaths; + std::vector<QPointF> current; + const auto finish = [&] { + // Qt's close repeats the start, where a CGPath's adds none. + if (!current.empty() && current.front() == current.back()) + current.pop_back(); + if (current.size() >= 3) + subpaths.push_back(current); + }; + for (int index = 0; index < path.elementCount(); ++index) { + const QPainterPath::Element element = path.elementAt(index); + if (element.isMoveTo()) { + finish(); + current = {element}; + } else if (element.isLineTo()) { + current.push_back(element); + } else if (element.type == QPainterPath::CurveToDataElement && (index + 1 == path.elementCount() || path.elementAt(index + 1).type != QPainterPath::CurveToDataElement)) { + // A curve's end point, as Swift keeps it. + current.push_back(element); + } + } + finish(); + QPainterPath result; + result.setFillRule(path.fillRule()); + for (const std::vector<QPointF> &subpath : subpaths) { + const std::vector<QPointF> points = chaikin(simplifyClosed(subpath, 1.6), 3); + result.moveTo(points.front()); + for (size_t index = 1; index < points.size(); ++index) + result.lineTo(points[index]); + result.closeSubpath(); + } + return result; +} + +void EditorSession::setWandMode(WandMode mode) +{ + m_wandMode = mode; + notify(); +} + +void EditorSession::setObjectSelectionSettings(const ObjectSelectionSettings &settings) +{ + m_objectSelectionSettings = settings; + notify(); +} + +void EditorSession::selectObject(QPointF point, SelectionMode mode, std::function<void()> done) +{ + // The caller resumes from the event loop, as after await. + const auto finish = [this, done] { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + const std::optional<CanvasDocument> &document = m_document; + // Swift's positive bounds refuse a point that is no number. + const bool inside = document && point.x() >= 0 && point.y() >= 0 && point.x() < document->width && point.y() < document->height; + const std::optional<QImage> sample = canEditSelection() && !m_selectionMoveOrigin && inside + ? selectionSample(*document, m_objectSelectionSettings.sampleAllLayers) + : std::nullopt; + if (!sample) { + finish(); + return; + } + setIsProjectBusy(true); + m_wanding = Wanding{mode, document->id, std::move(done), QStringLiteral("Object Selection")}; + m_wand.setFuture(QtConcurrent::run([sample = *sample, point, offset = m_objectSelectionSettings.edgeOffset, smooth = m_selectionAntialiased]() -> Wanded { + try { + return Wanded{ObjectSelection::select(sample, point, offset, smooth), std::nullopt}; + } catch (const SubjectRemovalError &error) { + return Wanded{std::nullopt, QString::fromUtf8(error.what())}; + } catch (const MagicWandError &error) { + return Wanded{std::nullopt, QString::fromUtf8(error.what())}; + } catch (const ExportError &error) { + return Wanded{std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} diff --git a/src/Document/ObjectSelection.h b/src/Document/ObjectSelection.h new file mode 100644 index 0000000..af16240 --- /dev/null +++ b/src/Document/ObjectSelection.h @@ -0,0 +1,26 @@ +#pragma once +#include <QImage> +#include <QPainterPath> +#include <optional> + +struct ObjectSelectionSettings { + // Read the visible composite rather than the active layer. + bool sampleAllLayers = true; + // Positive erodes the mask inward; negative grows it outward. + int edgeOffset = 0; + friend bool operator==(const ObjectSelectionSettings &, const ObjectSelectionSettings &) = default; +}; + +// Swift's ObjectSelection; U²-Net regions stand for Vision's instances. +namespace ObjectSelection { +// The model's grid the regions are found on. +inline constexpr int grid = 320; +// The outline in pixels; none off the image or background. +std::optional<QPainterPath> select(const QImage &image, QPointF point, int edgeOffset, bool smoothEdges); +// The binary grid's region holding the cell, else none. +std::optional<std::vector<uchar>> region(const std::vector<uchar> &mask, int width, int height, int x, int y); +// Swift's steps, a pixel ring each: erode inward, dilate outward. +std::vector<uchar> adjusted(std::vector<uchar> mask, int width, int height, int edgeOffset); +// Stair steps rounded off; winding and subpath order kept. +QPainterPath smoothed(const QPainterPath &path); +} diff --git a/src/Document/Selection+Modify.cpp b/src/Document/Selection+Modify.cpp new file mode 100644 index 0000000..f55a6d5 --- /dev/null +++ b/src/Document/Selection+Modify.cpp @@ -0,0 +1,83 @@ +#include "Document/EditorSession.h" +#include <cmath> + +// Select's Expand, Contract and Feather, and their amounts. +void EditorSession::setSelectionExpandAmount(int amount) +{ + m_selectionExpandAmount = amount; + notify(); +} + +void EditorSession::setSelectionContractAmount(int amount) +{ + m_selectionContractAmount = amount; + notify(); +} + +void EditorSession::setSelectionFeatherAmount(int amount) +{ + m_selectionFeatherAmount = amount; + notify(); +} + +void EditorSession::setSelectionAmountOperation(std::optional<SelectionAmountOperation> operation) +{ + m_selectionAmountOperation = operation; + resumeFileRequests(); + notify(); +} + +bool EditorSession::canModifySelection() const +{ + const std::optional<DocumentSelection> current = selection(); + return current && !current->isEmpty() && canEditSelection() && !m_lassoDraft; +} + +void EditorSession::expandSelection(int amount) +{ + resizeSelection(amount, QStringLiteral("Expand Selection")); +} + +void EditorSession::contractSelection(int amount) +{ + resizeSelection(-amount, QStringLiteral("Contract Selection")); +} + +void EditorSession::promptSelectionAmount(SelectionAmountOperation operation) +{ + if (!canModifySelection()) + return; + setSelectionAmountOperation(operation); +} + +void EditorSession::confirmSelectionAmount(int amount) +{ + const std::optional<SelectionAmountOperation> operation = m_selectionAmountOperation; + if (!operation || amount < 1 || amount > (*operation == SelectionAmountOperation::feather ? 250 : 500)) + return; + setSelectionAmountOperation(std::nullopt); + switch (*operation) { + case SelectionAmountOperation::expand: + setSelectionExpandAmount(amount); + expandSelection(amount); + break; + case SelectionAmountOperation::contract: + setSelectionContractAmount(amount); + contractSelection(amount); + break; + case SelectionAmountOperation::feather: + setSelectionFeatherAmount(amount); + featherSelection(amount); + break; + } +} + +void EditorSession::featherSelection(int amount) +{ + const std::optional<DocumentSelection> current = selection(); + if (!canModifySelection() || !current || amount <= 0) + return; + // Two soft edges spread less than their sum. + const double softened = std::sqrt(current->feather * current->feather + double(amount) * amount); + setSelection(DocumentSelection{current->path, current->antialiased, std::min(250.0, softened)}, QStringLiteral("Feather Selection")); +} diff --git a/src/Document/Selection+Session.h b/src/Document/Selection+Session.h index 33a4914..41a4a11 100644 --- a/src/Document/Selection+Session.h +++ b/src/Document/Selection+Session.h @@ -16,6 +16,7 @@ public: void cancelLasso(); void pressMarqueeKey(); void pressLassoKey(); + void pressWandKey(); void toggleMarqueeKind(); void toggleLassoKind(); // Closes the outline and combines it with the selection. diff --git a/src/Document/Selection.cpp b/src/Document/Selection.cpp index 4fda9e8..ba37f4e 100644 --- a/src/Document/Selection.cpp +++ b/src/Document/Selection.cpp @@ -54,6 +54,11 @@ QString rawValue(SelectionAmountOperation operation) throw std::logic_error("unknown selection amount operation"); } +QString rawValue(WandMode mode) +{ + return mode == WandMode::object ? QStringLiteral("Object") : QStringLiteral("Wand"); +} + QString rawValue(SelectionMode mode) { switch (mode) { @@ -173,31 +178,6 @@ void EditorSession::setSelectionAntialiased(bool antialiased) notify(); } -void EditorSession::setSelectionExpandAmount(int amount) -{ - m_selectionExpandAmount = amount; - notify(); -} - -void EditorSession::setSelectionContractAmount(int amount) -{ - m_selectionContractAmount = amount; - notify(); -} - -void EditorSession::setSelectionFeatherAmount(int amount) -{ - m_selectionFeatherAmount = amount; - notify(); -} - -void EditorSession::setSelectionAmountOperation(std::optional<SelectionAmountOperation> operation) -{ - m_selectionAmountOperation = operation; - resumeFileRequests(); - notify(); -} - void EditorSession::beginLasso(QPointF point, SelectionMode mode) { // The Magic Wand selects with a click, never an outline. @@ -266,6 +246,12 @@ void EditorSession::pressMarqueeKey() selectTool(NavigationTool::marquee); } +// W picks the Magic tool; Tab switches its two modes. +void EditorSession::pressWandKey() +{ + selectTool(NavigationTool::wand); +} + void EditorSession::pressLassoKey() { selectTool(NavigationTool::lasso); @@ -400,61 +386,6 @@ void EditorSession::nudgeSelection(double dx, double dy) endSelectionMove(); } -bool EditorSession::canModifySelection() const -{ - const std::optional<DocumentSelection> current = selection(); - return current && !current->isEmpty() && canEditSelection() && !m_lassoDraft; -} - -void EditorSession::expandSelection(int amount) -{ - resizeSelection(amount, QStringLiteral("Expand Selection")); -} - -void EditorSession::contractSelection(int amount) -{ - resizeSelection(-amount, QStringLiteral("Contract Selection")); -} - -void EditorSession::promptSelectionAmount(SelectionAmountOperation operation) -{ - if (!canModifySelection()) - return; - setSelectionAmountOperation(operation); -} - -void EditorSession::confirmSelectionAmount(int amount) -{ - const std::optional<SelectionAmountOperation> operation = m_selectionAmountOperation; - if (!operation || amount < 1 || amount > (*operation == SelectionAmountOperation::feather ? 250 : 500)) - return; - setSelectionAmountOperation(std::nullopt); - switch (*operation) { - case SelectionAmountOperation::expand: - setSelectionExpandAmount(amount); - expandSelection(amount); - break; - case SelectionAmountOperation::contract: - setSelectionContractAmount(amount); - contractSelection(amount); - break; - case SelectionAmountOperation::feather: - setSelectionFeatherAmount(amount); - featherSelection(amount); - break; - } -} - -void EditorSession::featherSelection(int amount) -{ - const std::optional<DocumentSelection> current = selection(); - if (!canModifySelection() || !current || amount <= 0) - return; - // Two soft edges spread less than their sum. - const double softened = std::sqrt(current->feather * current->feather + double(amount) * amount); - setSelection(DocumentSelection{current->path, current->antialiased, std::min(250.0, softened)}, QStringLiteral("Feather Selection")); -} - void EditorSession::resizeSelection(double delta, const QString &name) { const std::optional<DocumentSelection> current = selection(); diff --git a/src/Document/Selection.h b/src/Document/Selection.h index 9107cb7..4687665 100644 --- a/src/Document/Selection.h +++ b/src/Document/Selection.h @@ -4,6 +4,7 @@ #include <QPointF> #include <QRectF> #include <QString> +#include <array> #include <optional> #include <vector> @@ -31,6 +32,11 @@ struct DocumentSelection { friend bool operator==(const DocumentSelection &, const DocumentSelection &) = default; }; +// The Magic tool's modes: similar colours, or the object clicked. +enum class WandMode { wand, object }; +inline constexpr std::array allWandModes{WandMode::wand, WandMode::object}; +QString rawValue(WandMode mode); + // The Marquee's outlines are rectangle and ellipse. enum class LassoKind { freehand, polygonal, rectangle, ellipse }; // The name the tool bar shows. diff --git a/src/Document/SubjectRemoval.cpp b/src/Document/SubjectRemoval.cpp index c90f9b5..8ec316f 100644 --- a/src/Document/SubjectRemoval.cpp +++ b/src/Document/SubjectRemoval.cpp @@ -222,6 +222,11 @@ QImage SubjectRemoval::subjectMask(const QImage &image, const std::optional<QIma return combined; } +QImage SubjectRemoval::foreground(const QImage &image) +{ + return vision(image); +} + QImage SubjectRemoval::run(const QImage &image, const FilterSettings &settings) { // The preview refines a copy 1400 long at most: quick. diff --git a/src/Document/SubjectRemoval.h b/src/Document/SubjectRemoval.h index 3ef4d9d..075b679 100644 --- a/src/Document/SubjectRemoval.h +++ b/src/Document/SubjectRemoval.h @@ -23,6 +23,8 @@ std::vector<float> modelInput(const QImage &image); QImage modelMask(const float *prediction, QSize size); // White over the subject in the layer's grid, times `existing`. QImage subjectMask(const QImage &image, const std::optional<QImage> &existing, const FilterSettings &settings); +// The model's mask at the image's size, cached by image. +QImage foreground(const QImage &image); // The preview: the layer with its background cleared. QImage run(const QImage &image, const FilterSettings &settings); } diff --git a/src/Rendering/EditorCanvas+Keys.cpp b/src/Rendering/EditorCanvas+Keys.cpp index 7cc0294..db1980b 100644 --- a/src/Rendering/EditorCanvas+Keys.cpp +++ b/src/Rendering/EditorCanvas+Keys.cpp @@ -26,8 +26,6 @@ std::optional<NavigationTool> toolForKey(int key) return NavigationTool::shape; case Qt::Key_I: return NavigationTool::eyedropper; - case Qt::Key_W: - return NavigationTool::wand; case Qt::Key_A: return NavigationTool::idle; case Qt::Key_R: diff --git a/src/Rendering/EditorCanvas+Selection.cpp b/src/Rendering/EditorCanvas+Selection.cpp index ccba9a4..c20e557 100644 --- a/src/Rendering/EditorCanvas+Selection.cpp +++ b/src/Rendering/EditorCanvas+Selection.cpp @@ -23,9 +23,11 @@ QCursor CanvasView::lassoCursor(Qt::KeyboardModifiers modifiers, std::optional<Q if ((command || mode == SelectionMode::replace) && document && location && m_session.canMoveSelection(m_session.viewport.documentPoint(*location, document->size()))) return command ? pixelDragCursor(modifiers.testFlag(Qt::AltModifier), ratio) : moveSelectionCursor(ratio); - if (m_session.tool() == NavigationTool::wand) + const bool wand = m_session.tool() == NavigationTool::wand; + if (wand && m_session.wandMode() == WandMode::wand) return wandCursor(mode, ratio); - const SelectionIcon icon = m_session.tool() == NavigationTool::marquee + const SelectionIcon icon = wand ? SelectionIcon::objectSelection + : m_session.tool() == NavigationTool::marquee ? (m_session.marqueeKind() == LassoKind::ellipse ? SelectionIcon::ellipseMarquee : SelectionIcon::rectangleMarquee) : (m_session.lassoKind() == LassoKind::polygonal ? SelectionIcon::polygonalLasso : SelectionIcon::freehandLasso); return selectionCursor(icon, mode, ratio); @@ -56,6 +58,11 @@ void CanvasView::lassoMouseDown(QPointF point, Qt::KeyboardModifiers modifiers) updateCursor(); return; } + // Object mode runs before the colour wand, as Swift's. + if (m_session.tool() == NavigationTool::wand && m_session.wandMode() == WandMode::object) { + m_session.selectObject(pixel, mode); + return; + } if (m_session.tool() == NavigationTool::wand) { m_session.magicWand(pixel, mode); return; @@ -130,7 +137,9 @@ void CanvasView::releaseSelectionTool() const bool moved = m_session.selectionMoveOrigin() != m_session.selection(); m_session.endSelectionMove(); // The wand's click inside selects afresh; another's deselects. - if (!moved && m_session.tool() == NavigationTool::wand) + if (!moved && m_session.tool() == NavigationTool::wand && m_session.wandMode() == WandMode::object) + m_session.selectObject(start, SelectionMode::replace); + else if (!moved && m_session.tool() == NavigationTool::wand) m_session.magicWand(start, SelectionMode::replace); else if (!moved) m_session.deselect(); @@ -169,12 +178,14 @@ bool CanvasView::selectionKey(const QKeyEvent &key) key.key() == Qt::Key_Up ? -step : key.key() == Qt::Key_Down ? step : 0); return true; } - // M and L choose their tools; a repeat does nothing. - if (plain && (key.key() == Qt::Key_M || key.key() == Qt::Key_L)) { + // M, W and L choose tools; a repeat does nothing. + if (plain && (key.key() == Qt::Key_M || key.key() == Qt::Key_W || key.key() == Qt::Key_L)) { if (key.isAutoRepeat()) return true; if (key.key() == Qt::Key_M) m_session.pressMarqueeKey(); + else if (key.key() == Qt::Key_W) + m_session.pressWandKey(); else m_session.pressLassoKey(); updateCursor(); diff --git a/src/Rendering/SelectionIcons.cpp b/src/Rendering/SelectionIcons.cpp index bb7f162..707df63 100644 --- a/src/Rendering/SelectionIcons.cpp +++ b/src/Rendering/SelectionIcons.cpp @@ -32,6 +32,19 @@ void SelectionIcons::paint(QPainter &painter, SelectionIcon icon) painter.drawPath(tail); return; } + // Swift's ObjectSelectionToolIcon: four corners round a pointer. + case SelectionIcon::objectSelection: { + QPen pen = painter.pen(); + pen.setWidthF(1.6); + painter.setPen(pen); + for (const QPolygonF &corner : {QPolygonF{QPointF(2, 6), QPointF(2, 2), QPointF(6, 2)}, QPolygonF{QPointF(12, 2), QPointF(16, 2), QPointF(16, 6)}, + QPolygonF{QPointF(16, 12), QPointF(16, 16), QPointF(12, 16)}, QPolygonF{QPointF(6, 16), QPointF(2, 16), QPointF(2, 12)}}) + painter.drawPolyline(corner); + painter.setPen(Qt::NoPen); + painter.setBrush(pen.color()); + painter.drawPolygon(QPolygonF{QPointF(7, 5), QPointF(7, 14), QPointF(9.6, 11.7), QPointF(11.3, 15.3), QPointF(13.2, 14.4), QPointF(11.5, 10.9), QPointF(14.5, 10.9)}); + return; + } // Swift's PolygonalLassoToolIcon: loop, knot and rope as segments. case SelectionIcon::polygonalLasso: painter.drawPolygon(QPolygonF{QPointF(1.2, 7.0), QPointF(4.0, 2.4), QPointF(11.8, 1.8), QPointF(16.8, 5.2), QPointF(15.6, 10.4), QPointF(7.0, 11.6)}); diff --git a/src/Rendering/SelectionIcons.h b/src/Rendering/SelectionIcons.h index 940d09f..ef4716d 100644 --- a/src/Rendering/SelectionIcons.h +++ b/src/Rendering/SelectionIcons.h @@ -2,7 +2,7 @@ #include <QPainter> // Which selection tool an icon names, by its kind. -enum class SelectionIcon { freehandLasso, polygonalLasso, rectangleMarquee, ellipseMarquee }; +enum class SelectionIcon { freehandLasso, polygonalLasso, rectangleMarquee, ellipseMarquee, objectSelection }; // Icons the rail and the cursors share, 18-unit grid. namespace SelectionIcons { diff --git a/src/UI/KeyboardShortcuts.cpp b/src/UI/KeyboardShortcuts.cpp index bda52c1..ffa2db5 100644 --- a/src/UI/KeyboardShortcuts.cpp +++ b/src/UI/KeyboardShortcuts.cpp @@ -140,7 +140,7 @@ const std::vector<ShortcutDefinition> &ShortcutDefinition::all() const std::vector<std::pair<const char *, QString>> tools{ {"Select tool", "a"}, {"Move / Transform tool", "v"}, {"Hand tool", "h"}, {"Zoom tool", "z"}, {"Brush tool", "b"}, {"Eraser", "e"}, {"Spot Healing", "j"}, {"Clone Stamp", "s"}, {"Type tool", "t"}, {"Gradient tool", "g"}, - {"Shape tool", "u"}, {"Eyedropper tool", "i"}, {"Marquee / cycle shape", "m"}, {"Magic Wand", "w"}, + {"Shape tool", "u"}, {"Eyedropper tool", "i"}, {"Marquee / cycle shape", "m"}, {"Magic", "w"}, {"Lasso / cycle mode", "l"}, {"Blur / Smudge / Liquify", "r"}, {"Crop tool", "c"}, {"Swap foreground/background", "x"}, {"Reset colors", "d"}, {"Cycle tool mode", "\t"}, {"Temporary Hand tool (hold)", " "}, {"Delete selection / layer / effect / lasso point", backspace}, {"Apply current canvas operation", "\r"}, diff --git a/src/UI/LassoControls.cpp b/src/UI/LassoControls.cpp index fbca8ab..26278ec 100644 --- a/src/UI/LassoControls.cpp +++ b/src/UI/LassoControls.cpp @@ -17,15 +17,15 @@ SelectionAmountField::SelectionAmountField(EditorSession &session, int low, int setFont(ToolHeaderStyle::controlFont()); setFixedWidth(40); setAlignment(Qt::AlignRight | Qt::AlignVCenter); - // Digits only; the bounds clamp, as Swift's binding does. - setValidator(new QRegularExpressionValidator(QRegularExpression(QStringLiteral("[0-9]*")), this)); - // Typing applies at once; an overflow reads as the top. + // Digits, signed below zero; the bounds clamp, as Swift's binding. + setValidator(new QRegularExpressionValidator(QRegularExpression(low < 0 ? QStringLiteral("-?[0-9]*") : QStringLiteral("[0-9]*")), this)); + // Typing applies at once; an overflow reads as its end. connect(this, &QLineEdit::textEdited, this, [this](const QString &text) { - if (text.isEmpty()) + if (text.isEmpty() || text == QLatin1String("-")) return; bool fits = false; const qlonglong typed = text.toLongLong(&fits); - m_change(double(fits ? std::clamp<qlonglong>(typed, m_low, m_high) : m_high)); + m_change(double(fits ? std::clamp<qlonglong>(typed, m_low, m_high) : text.startsWith(QLatin1Char('-')) ? m_low : m_high)); }); } @@ -95,7 +95,23 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent) m_allLayers(choice(m_sources, QStringLiteral("wandAllLayers"), QStringLiteral("All Layers"), QStringLiteral("Read colors from the active layer only, or from every visible layer as shown"), [this] { changeWand([](WandSettings &wand) { wand.sampleAllLayers = true; }); })), - m_contiguous(new QCheckBox(QStringLiteral("Contiguous"), this)), + m_contiguous(new QCheckBox(QStringLiteral("Contiguous"), this)), m_wandModes(new QButtonGroup(this)), + m_wandMode(choice(m_wandModes, QStringLiteral("magicWandMode"), rawValue(WandMode::wand), QStringLiteral("Press Tab to switch between Wand and Object"), + [this] { m_session.setWandMode(WandMode::wand); })), + m_objectMode(choice(m_wandModes, QStringLiteral("magicObjectMode"), rawValue(WandMode::object), QStringLiteral("Press Tab to switch between Wand and Object"), + [this] { m_session.setWandMode(WandMode::object); })), + m_objectSources(new QButtonGroup(this)), + m_objectThisLayer(choice(m_objectSources, QStringLiteral("objectThisLayer"), QStringLiteral("This Layer"), + QStringLiteral("Analyze the active layer only, or every visible layer as shown"), + [this] { changeObject([](ObjectSelectionSettings &object) { object.sampleAllLayers = false; }); })), + m_objectAllLayers(choice(m_objectSources, QStringLiteral("objectAllLayers"), QStringLiteral("All Layers"), + QStringLiteral("Analyze the active layer only, or every visible layer as shown"), + [this] { changeObject([](ObjectSelectionSettings &object) { object.sampleAllLayers = true; }); })), + m_edgeLabel(new QLabel(QStringLiteral("Edge"), this)), + m_edge(new SelectionAmountField(session, -10, 10, [this] { return double(m_session.objectSelectionSettings().edgeOffset); }, + [this](double edge) { changeObject([edge](ObjectSelectionSettings &object) { object.edgeOffset = int(std::lround(edge)); }); }, + this)), + m_edgeUnit(new QLabel(QStringLiteral("px"), this)), m_antialias(new QCheckBox(QStringLiteral("Anti-alias"), this)), m_expand(new QPushButton(QStringLiteral("Expand"), this)), m_expandAmount(new SelectionAmountField(session, 1, 500, [this] { return double(m_session.selectionExpandAmount()); }, [this](double amount) { m_session.setSelectionExpandAmount(int(std::lround(amount))); }, this)), @@ -119,12 +135,16 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent) connect(m_sampleSize, &QComboBox::activated, this, [this](int index) { changeWand([index](WandSettings &wand) { wand.sampleSize = WandSampleSize(index); }); }); + m_edge->setObjectName(QStringLiteral("objectEdge")); + for (QWidget *edge : std::initializer_list<QWidget *>{m_edgeLabel, m_edge, m_edgeUnit}) + edge->setToolTip(QStringLiteral("Positive values tighten the detected mask inward; negative values expand it outward")); + m_edgeLabel->setObjectName(QStringLiteral("objectEdgeLabel")); + m_edgeLabel->setFont(ToolHeaderStyle::controlFont()); m_contiguous->setObjectName(QStringLiteral("wandContiguous")); m_contiguous->setToolTip(QStringLiteral("Select only similar pixels connected to the one you click; off selects them everywhere")); connect(m_contiguous, &QCheckBox::toggled, this, [this](bool on) { changeWand([on](WandSettings &wand) { wand.contiguous = on; }); }); // Rectangles snap to whole pixels, so smoothing applies elsewhere. m_antialias->setObjectName(QStringLiteral("selectionAntialiased")); - m_antialias->setToolTip(QStringLiteral("Smooth selection edges; turn off for hard pixel edges")); connect(m_antialias, &QCheckBox::toggled, this, [this](bool on) { m_session.setSelectionAntialiased(on); }); auto *divider = new QFrame(this); divider->setFrameShape(QFrame::VLine); @@ -150,8 +170,9 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent) m_deselect->setObjectName(QStringLiteral("deselect")); connect(m_deselect, &QPushButton::clicked, this, [this] { m_session.deselect(); }); // Swift's `unitSuffix`: a unit sits by each field. - for (QWidget *widget : std::initializer_list<QWidget *>{m_rectangle, m_ellipse, m_freehand, m_polygonal, m_replace, m_add, m_subtract, m_toleranceLabel, - m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous, m_antialias, + for (QWidget *widget : std::initializer_list<QWidget *>{m_rectangle, m_ellipse, m_wandMode, m_objectMode, m_freehand, m_polygonal, m_replace, m_add, + m_subtract, m_toleranceLabel, m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous, + m_objectThisLayer, m_objectAllLayers, m_edgeLabel, m_edge, m_edgeUnit, m_antialias, divider, m_expand, m_expandAmount, new QLabel(QStringLiteral("px"), this), m_contract, m_contractAmount, new QLabel(QStringLiteral("px"), this), m_feather, m_featherAmount, new QLabel(QStringLiteral("px"), this)}) @@ -170,6 +191,14 @@ void LassoControls::changeWand(const std::function<void(WandSettings &)> &change m_session.setWandSettings(wand); } +// One field of Object mode's settings changes; the rest stay. +void LassoControls::changeObject(const std::function<void(ObjectSelectionSettings &)> &change) +{ + ObjectSelectionSettings object = m_session.objectSelectionSettings(); + change(object); + m_session.setObjectSelectionSettings(object); +} + // A segment of Swift's segmented picker: one checkable button. QToolButton *LassoControls::choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick) { @@ -188,7 +217,18 @@ QToolButton *LassoControls::choice(QButtonGroup *group, const QString &name, con void LassoControls::synchronize() { const NavigationTool tool = m_session.tool(); - title->setText(tool == NavigationTool::marquee ? QStringLiteral("Marquee") : tool == NavigationTool::wand ? QStringLiteral("Magic Wand") : QStringLiteral("Lasso")); + title->setText(tool == NavigationTool::marquee ? QStringLiteral("Marquee") : tool == NavigationTool::wand ? QStringLiteral("Magic") : QStringLiteral("Lasso")); + const bool wand = tool == NavigationTool::wand; + const bool object = wand && m_session.wandMode() == WandMode::object; + for (QToolButton *button : {m_wandMode, m_objectMode}) + button->setVisible(wand); + m_wandMode->setChecked(!object); + m_objectMode->setChecked(object); + for (QWidget *widget : std::initializer_list<QWidget *>{m_objectThisLayer, m_objectAllLayers, m_edgeLabel, m_edge, m_edgeUnit}) + widget->setVisible(object); + m_objectThisLayer->setChecked(!m_session.objectSelectionSettings().sampleAllLayers); + m_objectAllLayers->setChecked(m_session.objectSelectionSettings().sampleAllLayers); + m_edge->sync(m_session.objectSelectionSettings().edgeOffset); for (QToolButton *button : {m_rectangle, m_ellipse}) button->setVisible(tool == NavigationTool::marquee); for (QToolButton *button : {m_freehand, m_polygonal}) @@ -202,16 +242,18 @@ void LassoControls::synchronize() m_replace->setChecked(mode == SelectionMode::replace); m_add->setChecked(mode == SelectionMode::add); m_subtract->setChecked(mode == SelectionMode::subtract); - const WandSettings &wand = m_session.wandSettings(); + const WandSettings &colours = m_session.wandSettings(); for (QWidget *widget : std::initializer_list<QWidget *>{m_toleranceLabel, m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous}) - widget->setVisible(tool == NavigationTool::wand); - m_tolerance->sync(wand.tolerance); - m_sampleSize->setCurrentIndex(int(wand.sampleSize)); - m_thisLayer->setChecked(!wand.sampleAllLayers); - m_allLayers->setChecked(wand.sampleAllLayers); - if (m_contiguous->isChecked() != wand.contiguous) - m_contiguous->setChecked(wand.contiguous); + widget->setVisible(wand && !object); + m_tolerance->sync(colours.tolerance); + m_sampleSize->setCurrentIndex(int(colours.sampleSize)); + m_thisLayer->setChecked(!colours.sampleAllLayers); + m_allLayers->setChecked(colours.sampleAllLayers); + if (m_contiguous->isChecked() != colours.contiguous) + m_contiguous->setChecked(colours.contiguous); m_antialias->setVisible(tool == NavigationTool::lasso || tool == NavigationTool::wand || (tool == NavigationTool::marquee && m_session.marqueeKind() == LassoKind::ellipse)); + m_antialias->setToolTip(object ? QStringLiteral("Smooth the detected object outline; turn off for the raw pixel mask") + : QStringLiteral("Smooth selection edges; turn off for hard pixel edges")); if (m_antialias->isChecked() != m_session.selectionAntialiased()) m_antialias->setChecked(m_session.selectionAntialiased()); const bool modifies = m_session.canModifySelection(); diff --git a/src/UI/LassoControls.h b/src/UI/LassoControls.h index 669eceb..3d00634 100644 --- a/src/UI/LassoControls.h +++ b/src/UI/LassoControls.h @@ -45,6 +45,7 @@ public: private: QToolButton *choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick); void changeWand(const std::function<void(WandSettings &)> &change); + void changeObject(const std::function<void(ObjectSelectionSettings &)> &change); void synchronize(); EditorSession &m_session; @@ -66,6 +67,16 @@ private: QToolButton *const m_thisLayer; QToolButton *const m_allLayers; QCheckBox *const m_contiguous; + // The Magic tool's modes, and Object mode's own settings. + QButtonGroup *const m_wandModes; + QToolButton *const m_wandMode; + QToolButton *const m_objectMode; + QButtonGroup *const m_objectSources; + QToolButton *const m_objectThisLayer; + QToolButton *const m_objectAllLayers; + QLabel *const m_edgeLabel; + SelectionAmountField *const m_edge; + QLabel *const m_edgeUnit; QCheckBox *const m_antialias; QPushButton *const m_expand; SelectionAmountField *const m_expandAmount; diff --git a/src/UI/NativeLayerList.cpp b/src/UI/NativeLayerList.cpp index 48e7ec9..b8234ab 100644 --- a/src/UI/NativeLayerList.cpp +++ b/src/UI/NativeLayerList.cpp @@ -187,7 +187,7 @@ void NativeLayerList::pressKey(QKeyEvent *event) static const std::map<int, NavigationTool> tools{ {Qt::Key_A, NavigationTool::idle}, {Qt::Key_V, NavigationTool::move}, {Qt::Key_H, NavigationTool::hand}, {Qt::Key_Z, NavigationTool::zoom}, {Qt::Key_G, NavigationTool::gradient}, {Qt::Key_L, NavigationTool::lasso}, {Qt::Key_M, NavigationTool::marquee}, - {Qt::Key_W, NavigationTool::wand}, {Qt::Key_J, NavigationTool::spotHealing}, {Qt::Key_S, NavigationTool::cloneStamp}, + {Qt::Key_J, NavigationTool::spotHealing}, {Qt::Key_S, NavigationTool::cloneStamp}, {Qt::Key_U, NavigationTool::shape}, {Qt::Key_R, NavigationTool::blur}, {Qt::Key_I, NavigationTool::eyedropper}, {Qt::Key_C, NavigationTool::crop}, {Qt::Key_T, NavigationTool::type}}; if (plain && (key == Qt::Key_Backspace || key == Qt::Key_Delete)) { @@ -209,6 +209,10 @@ void NativeLayerList::pressKey(QKeyEvent *event) m_session.swapPaletteColors(); else m_session.resetPaletteColors(); + } else if (plain && key == Qt::Key_W) { + // A held W picks the Magic tool once, as Swift's. + if (!event->isAutoRepeat()) + m_session.pressWandKey(); } else if (plain && tools.contains(key)) { m_session.selectTool(tools.at(key)); } else if (plain && event->text().size() == 1 && event->text().front().isDigit() && m_session.usesOpacityKeys()) { diff --git a/src/UI/ToolIcons.cpp b/src/UI/ToolIcons.cpp index 39662bd..695a9c6 100644 --- a/src/UI/ToolIcons.cpp +++ b/src/UI/ToolIcons.cpp @@ -211,7 +211,7 @@ void ToolIcons::paint(QPainter &painter, NavigationTool tool, QPointF origin, do case NavigationTool::move: move(painter); break; case NavigationTool::marquee: marquee(painter, kind); break; case NavigationTool::lasso: lasso(painter, kind); break; - case NavigationTool::wand: wand(painter); break; + case NavigationTool::wand: kind == ToolIconKind::object ? SelectionIcons::paint(painter, SelectionIcon::objectSelection) : wand(painter); break; case NavigationTool::crop: crop(painter); break; case NavigationTool::brush: kind == ToolIconKind::eraser ? eraser(painter) : brush(painter); break; case NavigationTool::spotHealing: spotHealing(painter); break; diff --git a/src/UI/ToolIcons.h b/src/UI/ToolIcons.h index 35f505c..d8ddea0 100644 --- a/src/UI/ToolIcons.h +++ b/src/UI/ToolIcons.h @@ -5,7 +5,7 @@ #include <QRectF> // The Marquee's and the Lasso's icons follow their kinds. -enum class ToolIconKind { plain, ellipse, polygonal, eraser }; +enum class ToolIconKind { plain, ellipse, polygonal, eraser, object }; // The tool rail's icons, drawn: SF Symbols have no twin. namespace ToolIcons { diff --git a/tests/CompositorUITestsLaunchTests.cpp b/tests/CompositorUITestsLaunchTests.cpp index bb8dbda..80402f4 100644 --- a/tests/CompositorUITestsLaunchTests.cpp +++ b/tests/CompositorUITestsLaunchTests.cpp @@ -20,7 +20,7 @@ void CompositorUITestsLaunchTests::testLaunch() // The welcome is up: Swift's launch test waits for it. QVERIFY(window.findChild<QWidget *>("createCanvas")->isVisible()); QCoreApplication::processEvents(); - QVERIFY(window.grab().save(QStringLiteral("LaunchScreen.png"))); + QVERIFY(window.grab().save(QCoreApplication::applicationDirPath() + QStringLiteral("/LaunchScreen.png"))); } void CompositorUITestsLaunchTests::testWindowKeepsItsMinimumSize() diff --git a/tests/ContentViewStatusTests.cpp b/tests/ContentViewStatusTests.cpp index 9bbf324..78d215c 100644 --- a/tests/ContentViewStatusTests.cpp +++ b/tests/ContentViewStatusTests.cpp @@ -141,7 +141,7 @@ void ContentViewStatusTests::everyToolHasItsHint() {NavigationTool::move, "Drag to move · Handles to resize · Circle to rotate · 1–0 layer opacity · Space to pan"}, {NavigationTool::marquee, "Drag a rectangle · Shift add · Alt subtract · Shift again mid-drag square · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"}, {NavigationTool::lasso, "Drag to select · Drag inside to move · Shift add · Alt subtract · Delete clears · Alt+Backspace/Ctrl+Backspace fill · Ctrl+D deselect"}, - {NavigationTool::wand, "Click to select similar colors · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"}, + {NavigationTool::wand, "Click to select similar colors · Tab for Object · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"}, {NavigationTool::crop, "Drag to crop · Enter apply · Escape cancel · Space to pan"}, {NavigationTool::brush, "Drag to paint · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan"}, {NavigationTool::spotHealing, "Drag over blemishes to heal · [ ] size · Shift-[ ] hardness · Escape cancel · Space to pan"}, @@ -159,6 +159,7 @@ void ContentViewStatusTests::everyToolHasItsHint() QCOMPARE(ContentView::hint(NavigationTool::move, ToolIconKind::eraser), ContentView::hint(NavigationTool::move)); QCOMPARE(ContentView::hint(NavigationTool::lasso, ToolIconKind::ellipse), ContentView::hint(NavigationTool::lasso)); QCOMPARE(ContentView::hint(NavigationTool::marquee, ToolIconKind::polygonal), ContentView::hint(NavigationTool::marquee)); + QCOMPARE(ContentView::hint(NavigationTool::lasso, ToolIconKind::object), ContentView::hint(NavigationTool::lasso)); EditorSession session; session.createDocument(8, 8); ContentView view(session); @@ -166,6 +167,11 @@ void ContentViewStatusTests::everyToolHasItsHint() session.selectTool(tool); QCOMPARE(find<QLabel>(view, "activityStatus").text(), QString::fromUtf8(words)); } + // Magic's words follow its mode. + session.selectTool(NavigationTool::wand); + session.setWandMode(WandMode::object); + QCOMPARE(find<QLabel>(view, "activityStatus").text(), + QString("Click an object to select its outline · Tab for Wand · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect")); // The Shape's Shift follows its kind. session.selectTool(NavigationTool::shape); session.setShapeKind(ShapeKind::ellipse); diff --git a/tests/ContentViewTests.cpp b/tests/ContentViewTests.cpp index ce690fb..7a2c386 100644 --- a/tests/ContentViewTests.cpp +++ b/tests/ContentViewTests.cpp @@ -32,7 +32,7 @@ const std::pair<NavigationTool, const char *> labelled[] = { {NavigationTool::move, "Move / Transform (V)"}, {NavigationTool::marquee, "Marquee (M)"}, {NavigationTool::lasso, "Lasso (L)"}, - {NavigationTool::wand, "Magic Wand (W)"}, + {NavigationTool::wand, "Magic (W) · Tab switches Wand and Object"}, {NavigationTool::crop, "Crop (C)"}, {NavigationTool::brush, "Brush (B) · Eraser (E)"}, {NavigationTool::spotHealing, "Spot Healing Brush (J)"}, @@ -167,6 +167,13 @@ void ContentViewTests::theRailHoldsEveryToolInSwiftsOrder() QVERIFY(brush->grab().toImage() != painting); session.setBrushMode(BrushToolMode::paint); QCOMPARE(brush->grab().toImage(), painting); + // Magic's shows the object icon in Object mode. + QToolButton *const magic = buttons[3]; + const QImage colours = magic->grab().toImage(); + session.setWandMode(WandMode::object); + QVERIFY(magic->grab().toImage() != colours); + session.setWandMode(WandMode::wand); + QCOMPARE(magic->grab().toImage(), colours); } void ContentViewTests::theBarFollowsTheTool() @@ -236,7 +243,7 @@ void ContentViewTests::theBarFollowsTheTool() QCOMPARE(bar().title->text(), QString("Lasso")); session.selectTool(NavigationTool::wand); QVERIFY(bar().property("kept").toBool()); - QCOMPARE(bar().title->text(), QString("Magic Wand")); + QCOMPARE(bar().title->text(), QString("Magic")); QCOMPARE(bar().height(), 42); QCOMPARE(bar().height(), 42); } diff --git a/tests/LassoControlsTests.cpp b/tests/LassoControlsTests.cpp index 9f216c7..9075ddf 100644 --- a/tests/LassoControlsTests.cpp +++ b/tests/LassoControlsTests.cpp @@ -33,6 +33,7 @@ private slots: void expandContractAndTheirAmountsNeedASelection(); void deselectTheEmptyWordsAndAntialiasFollowTheSession(); void theWandsControlsWriteItsSettings(); + void magicsModesAndObjectControlsWriteTheirSettings(); }; void LassoControlsTests::theBarShowsTheToolsKindsAndSwitchesThem() @@ -63,7 +64,7 @@ void LassoControlsTests::theBarShowsTheToolsKindsAndSwitchesThem() QVERIFY(bar.button("marqueeEllipse").isChecked() && !bar.button("marqueeRectangle").isChecked()); QVERIFY(find<QCheckBox>(bar.controls, "selectionAntialiased").isVisible()); session.selectTool(NavigationTool::wand); - QCOMPARE(bar.controls.title->text(), QString("Magic Wand")); + QCOMPARE(bar.controls.title->text(), QString("Magic")); QVERIFY(!bar.button("marqueeRectangle").isVisible() && !bar.button("lassoFreehand").isVisible()); QVERIFY(find<QCheckBox>(bar.controls, "selectionAntialiased").isVisible()); QCOMPARE(bar.controls.height(), 42); @@ -254,6 +255,10 @@ void LassoControlsTests::theWandsControlsWriteItsSettings() QTest::keyClicks(&tolerance, "0"); QTest::keyClick(&tolerance, Qt::Key_Down); QCOMPARE(session.wandSettings().tolerance, 0); + // A field that stays above zero takes no sign. + tolerance.selectAll(); + QTest::keyClicks(&tolerance, "-"); + QCOMPARE(tolerance.text(), QString("0")); QTest::keyClick(&tolerance, Qt::Key_Return); // The other three write their fields and read them back. size.setCurrentIndex(2); @@ -271,5 +276,97 @@ void LassoControlsTests::theWandsControlsWriteItsSettings() QVERIFY(bar.button("wandThisLayer").isChecked() && contiguous.isChecked()); } +void LassoControlsTests::magicsModesAndObjectControlsWriteTheirSettings() +{ + Bar bar; + EditorSession &session = *bar.session; + auto &edge = find<QLineEdit>(bar.controls, "objectEdge"); + auto &antialias = find<QCheckBox>(bar.controls, "selectionAntialiased"); + QToolButton &wand = bar.button("magicWandMode"), &object = bar.button("magicObjectMode"); + // The modes show with Magic alone, labelled as Swift's. + QVERIFY(!wand.isVisible() && !object.isVisible()); + session.selectTool(NavigationTool::wand); + QVERIFY(wand.isVisible() && object.isVisible() && wand.isChecked() && !object.isChecked()); + QCOMPARE(wand.text(), QString("Wand")); + QCOMPARE(object.text(), QString("Object")); + QCOMPARE(object.toolTip(), QString("Press Tab to switch between Wand and Object")); + QCOMPARE(antialias.toolTip(), QString("Smooth selection edges; turn off for hard pixel edges")); + QVERIFY(!edge.isVisible() && !bar.button("objectThisLayer").isVisible()); + // Magic takes no outline, so a choice only switches. + session.beginLasso(QPointF(5, 5), SelectionMode::replace); + QVERIFY(!session.lassoDraft()); + QTest::mouseClick(&object, Qt::LeftButton); + QCOMPARE(session.wandMode(), WandMode::object); + QVERIFY(object.isChecked() && !wand.isChecked()); + // Object shows its own controls and hides the wand's. + QVERIFY(edge.isVisible() && bar.button("objectThisLayer").isVisible() && bar.button("objectAllLayers").isVisible()); + QVERIFY(find<QLabel>(bar.controls, "objectEdgeLabel").isVisible()); + QVERIFY(!find<QLineEdit>(bar.controls, "wandTolerance").isVisible() && !find<QCheckBox>(bar.controls, "wandContiguous").isVisible()); + QVERIFY(!bar.button("wandThisLayer").isVisible()); + QCOMPARE(antialias.toolTip(), QString("Smooth the detected object outline; turn off for the raw pixel mask")); + QCOMPARE(bar.button("objectAllLayers").toolTip(), QString("Analyze the active layer only, or every visible layer as shown")); + QCOMPARE(edge.toolTip(), QString("Positive values tighten the detected mask inward; negative values expand it outward")); + QVERIFY(bar.button("objectAllLayers").isChecked() && !bar.button("objectThisLayer").isChecked()); + QCOMPARE(edge.text(), QString("0")); + // The source writes its field alone. + QTest::mouseClick(&bar.button("objectThisLayer"), Qt::LeftButton); + QVERIFY(!session.objectSelectionSettings().sampleAllLayers); + QTest::mouseClick(&bar.button("objectAllLayers"), Qt::LeftButton); + QVERIFY(session.objectSelectionSettings().sampleAllLayers); + // The edge takes a sign, clamped to ten either way. + edge.setFocus(); + QTRY_VERIFY(edge.hasFocus()); + edge.selectAll(); + QTest::keyClicks(&edge, "-"); + QCOMPARE(session.objectSelectionSettings().edgeOffset, 0); + // A sign leads the digits, never follows them. + QTest::keyClicks(&edge, "-"); + QCOMPARE(edge.text(), QString("-")); + QTest::keyClicks(&edge, "4"); + QCOMPARE(session.objectSelectionSettings().edgeOffset, -4); + QTest::keyClicks(&edge, "0"); + QCOMPARE(session.objectSelectionSettings().edgeOffset, -10); + edge.selectAll(); + QTest::keyClicks(&edge, "12"); + QCOMPARE(session.objectSelectionSettings().edgeOffset, 10); + edge.selectAll(); + edge.insert(QStringLiteral("-99999999999999999999")); + QCOMPARE(session.objectSelectionSettings().edgeOffset, -10); + edge.selectAll(); + edge.insert(QStringLiteral("99999999999999999999")); + QCOMPARE(session.objectSelectionSettings().edgeOffset, 10); + edge.selectAll(); + QTest::keyClick(&edge, Qt::Key_Backspace); + QTest::keyClicks(&edge, "a+"); + QCOMPARE(edge.text(), QString()); + QCOMPARE(session.objectSelectionSettings().edgeOffset, 10); + QTest::keyClicks(&edge, "3"); + QTest::keyClick(&edge, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(session.objectSelectionSettings().edgeOffset, -7); + QTest::keyClick(&edge, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(session.objectSelectionSettings().edgeOffset, -10); + QTest::keyClick(&edge, Qt::Key_Up); + QCOMPARE(session.objectSelectionSettings().edgeOffset, -9); + QVERIFY(session.objectSelectionSettings().sampleAllLayers); + QTest::keyClick(&edge, Qt::Key_Return); + // The session's own changes show; Wand brings its controls back. + ObjectSelectionSettings other; + other.sampleAllLayers = false; + other.edgeOffset = 6; + session.setObjectSelectionSettings(other); + QCOMPARE(edge.text(), QString("6")); + QVERIFY(bar.button("objectThisLayer").isChecked()); + session.cycleToolMode(); + QVERIFY(wand.isChecked() && !edge.isVisible() && find<QLineEdit>(bar.controls, "wandTolerance").isVisible()); + session.setWandMode(WandMode::object); + QVERIFY(object.isChecked() && !wand.isChecked()); + QTest::mouseClick(&object, Qt::LeftButton); + QTest::mouseClick(&wand, Qt::LeftButton); + QCOMPARE(session.wandMode(), WandMode::wand); + // The Lasso shows neither. + session.selectTool(NavigationTool::lasso); + QVERIFY(!wand.isVisible() && !edge.isVisible()); +} + QTEST_MAIN(LassoControlsTests) #include "LassoControlsTests.moc" diff --git a/tests/ObjectSelectionTests.cpp b/tests/ObjectSelectionTests.cpp new file mode 100644 index 0000000..1f06c8a --- /dev/null +++ b/tests/ObjectSelectionTests.cpp @@ -0,0 +1,475 @@ +#include "SelectionCanvasFixtures.h" +#include "Document/BrushStroke.h" +#include "Document/MagicWand.h" +#include "Document/ObjectSelection.h" +#include "Rendering/SelectionIcons.h" +#include "UI/NativeLayerList.h" +#include <QPainter> + +// The Magic tool's Object mode: regions, edges, outlines, clicks. +namespace { +// Red discs on white, or flat white. +QImage discs(const std::vector<std::pair<QPointF, double>> &shapes, QSize size = QSize(400, 300)) +{ + QImage image = BrushRaster::context(size.width(), size.height(), false); + image.fill(Qt::white); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(217, 26, 26)); + for (const auto &[centre, radius] : shapes) + painter.drawEllipse(centre, radius, radius); + return image; +} + +// Pixels an outline covers at half coverage or more. +int covered(const std::optional<QPainterPath> &path, QSize size) +{ + const QImage coverage = DocumentSelection{path.value(), false}.coverage(size.width(), size.height()); + int count = 0; + for (int y = 0; y < size.height(); ++y) { + for (int x = 0; x < size.width(); ++x) + count += coverage.constScanLine(y)[x] >= 128; + } + return count; +} + +bool inside(const std::optional<QPainterPath> &path, QPointF point) +{ + return path.value().contains(point); +} + +std::unique_ptr<EditorSession> withDisc() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(400, 300); + const QImage image = discs({{QPointF(200, 150), 80}}); + session->insert(ImportedImage(image, image, QStringLiteral("Disc"))); + session->selectTool(NavigationTool::wand); + session->setWandMode(WandMode::object); + return session; +} + +void selectObject(EditorSession &session, QPointF point, SelectionMode mode) +{ + bool done = false; + session.selectObject(point, mode, [&done] { done = true; }); + QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000)); +} + +// Points of the path, a subpath's close included. +int points(const QPainterPath &path) +{ + return path.elementCount(); +} +} + +class ObjectSelectionTests : public QObject { + Q_OBJECT +private slots: + void aRegionTakesTheEightConnectedCellsUnderTheClick(); + void edgesStepARingAPixelAndStopAtTen(); + void smoothingRoundsStairsAndKeepsSubpathsAndTheirRule(); + void theObjectUnderTheClickIsTracedAndTheBackgroundIsNone(); + void theEdgeOffsetTightensAndLoosensTheOutline(); + void theEdgeFollowsTheImageNotTheModelsGrid(); + void theSessionSelectsCombinesAndNamesTheStep(); + void theSessionRefusesAndReadsTheLayerItIsTold(); + void theModeSwitchesByTabAndWKeepsIt(); + void theCanvasClicksPicksAgainInsideAndShowsTheObjectCursor(); +}; + +void ObjectSelectionTests::aRegionTakesTheEightConnectedCellsUnderTheClick() +{ + const std::vector<uchar> mask{ + 1, 1, 0, 0, 1, + 0, 1, 0, 0, 1, + 0, 0, 1, 0, 0, + 1, 0, 0, 0, 1, + }; + const std::vector<uchar> first = ObjectSelection::region(mask, 5, 4, 0, 0).value(); + QCOMPARE(first, (std::vector<uchar>{ + 1, 1, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 0, 0, 0, + })); + // One region from any cell, the diagonal included. + QCOMPARE(ObjectSelection::region(mask, 5, 4, 2, 2).value(), first); + QCOMPARE(ObjectSelection::region(mask, 5, 4, 4, 1).value(), (std::vector<uchar>{ + 0, 0, 0, 0, 1, + 0, 0, 0, 0, 1, + 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, + })); + QCOMPARE(ObjectSelection::region(mask, 5, 4, 4, 3).value(), (std::vector<uchar>{ + 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, + })); + // Background holds no region. + QVERIFY(!ObjectSelection::region(mask, 5, 4, 3, 0)); +} + +void ObjectSelectionTests::edgesStepARingAPixelAndStopAtTen() +{ + std::vector<uchar> block(49); + for (int y = 2; y <= 4; ++y) { + for (int x = 2; x <= 4; ++x) + block[size_t(y * 7 + x)] = 255; + } + QCOMPARE(ObjectSelection::adjusted(block, 7, 7, 0), block); + std::vector<uchar> centre(49); + centre[24] = 255; + QCOMPARE(ObjectSelection::adjusted(block, 7, 7, 1), centre); + QCOMPARE(ObjectSelection::adjusted(block, 7, 7, 2), std::vector<uchar>(49)); + std::vector<uchar> grown(49); + for (int y = 1; y <= 5; ++y) { + for (int x = 1; x <= 5; ++x) + grown[size_t(y * 7 + x)] = 255; + } + QCOMPARE(ObjectSelection::adjusted(block, 7, 7, -1), grown); + // Past ten steps either way, ten. + std::vector<uchar> dot(25 * 25); + dot[12 * 25 + 12] = 255; + const std::vector<uchar> far = ObjectSelection::adjusted(dot, 25, 25, -20); + QCOMPARE(far, ObjectSelection::adjusted(dot, 25, 25, -10)); + QCOMPARE(std::count(far.begin(), far.end(), uchar(255)), 21 * 21); + QCOMPARE(far[2 * 25 + 2], uchar(255)); + QCOMPARE(far[1 * 25 + 12], uchar(0)); + std::vector<uchar> full(25 * 25, 255); + QCOMPARE(ObjectSelection::adjusted(far, 25, 25, 20), ObjectSelection::adjusted(far, 25, 25, 10)); + // The image's edge counts as inside: a full mask keeps. + QCOMPARE(ObjectSelection::adjusted(full, 25, 25, 3), full); +} + +void ObjectSelectionTests::smoothingRoundsStairsAndKeepsSubpathsAndTheirRule() +{ + // A staircase triangle and a holed square, traced. + std::vector<uchar> mask(40 * 40); + for (int y = 0; y < 40; ++y) { + for (int x = 0; x < 40; ++x) + mask[size_t(y * 40 + x)] = (x <= y && y < 20) || (x >= 24 && x < 36 && y >= 24 && y < 36 && !(x >= 28 && x < 32 && y >= 28 && y < 32)) ? 255 : 0; + } + const QPainterPath traced = MagicWand::outline(mask, 40, 40).value(); + QVERIFY(points(traced) > 40); + const QPainterPath smooth = ObjectSelection::smoothed(traced); + QCOMPARE(smooth.fillRule(), traced.fillRule()); + // Three subpaths, each simplified, then cut three times. + const QList<QPolygonF> parts = smooth.toSubpathPolygons(); + QCOMPARE(parts.size(), 3); + QCOMPARE(parts[0].size(), 3 * 8 + 1); + QCOMPARE(parts[1].size(), 4 * 8 + 1); + QCOMPARE(parts[2].size(), 4 * 8 + 1); + // The stair is gone: the hypotenuse is straight. + QVERIFY(smooth.contains(QPointF(8, 12)) && !smooth.contains(QPointF(12, 8))); + // Corners round off; the hole stays a hole. + QVERIFY(smooth.contains(QPointF(30, 25)) && !smooth.contains(QPointF(30, 30))); + QVERIFY(!smooth.contains(QPointF(24.3, 24.3))); + QVERIFY(traced.contains(QPointF(24.3, 24.3))); + // By hand: the extreme breaks the loop; curves keep ends. + QPainterPath open; + open.moveTo(0, 0); + for (const QPointF point : {QPointF(5, -1.3), QPointF(10, 0), QPointF(10, 10), QPointF(0, 10), QPointF(0, 5)}) + open.lineTo(point); + open.moveTo(20, 0); + open.lineTo(30, 0); + open.cubicTo(30, 3, 30, 7, 30, 10); + open.lineTo(20, 10); + const QList<QPolygonF> built = ObjectSelection::smoothed(open).toSubpathPolygons(); + QCOMPARE(built.size(), 2); + QCOMPARE(built[0].size(), 33); + QCOMPARE(built[0].front(), QPointF(4.375, 0)); + QCOMPARE(built[1].size(), 33); + QCOMPARE(built[1].front(), QPointF(24.375, 0)); + QCOMPARE(built[1][8], QPointF(30, 4.375)); + // A flat loop keeps its points; a closed pair goes. + QPainterPath flat; + flat.moveTo(40, 0); + for (const QPointF point : {QPointF(45, 0), QPointF(50, 0), QPointF(45, 0.5)}) + flat.lineTo(point); + flat.closeSubpath(); + flat.moveTo(60, 0); + flat.lineTo(70, 0); + flat.closeSubpath(); + const QList<QPolygonF> kept = ObjectSelection::smoothed(flat).toSubpathPolygons(); + QCOMPARE(kept.size(), 1); + QCOMPARE(kept[0].size(), 33); + // A subpath under three points is dropped. + QPainterPath thin; + thin.moveTo(0, 0); + thin.lineTo(5, 5); + QVERIFY(ObjectSelection::smoothed(thin).isEmpty()); +} + +void ObjectSelectionTests::theObjectUnderTheClickIsTracedAndTheBackgroundIsNone() +{ + const QImage pair = discs({{QPointF(110, 150), 60}, {QPointF(290, 150), 60}}); + const std::optional<QPainterPath> left = ObjectSelection::select(pair, QPointF(110, 150), 0, false); + QVERIFY(inside(left, QPointF(110, 150)) && inside(left, QPointF(160, 150))); + QVERIFY(!inside(left, QPointF(290, 150)) && !inside(left, QPointF(10, 10)) && !inside(left, QPointF(178, 150))); + const std::optional<QPainterPath> right = ObjectSelection::select(pair, QPointF(290.7, 150.2), 0, false); + QVERIFY(inside(right, QPointF(290, 150)) && !inside(right, QPointF(110, 150))); + // The raw mask hugs the disc, within two percent. + const int area = covered(left, pair.size()); + QVERIFY2(std::abs(area - M_PI * 60 * 60) < 0.02 * M_PI * 60 * 60, qPrintable(QString::number(area))); + // Background, off the image, no number, or no subject: none. + QVERIFY(!ObjectSelection::select(pair, QPointF(10, 10), 0, false)); + QVERIFY(!ObjectSelection::select(pair, QPointF(200, 150), 0, false)); + for (const QPointF point : {QPointF(-0.5, 10), QPointF(400, 10), QPointF(10, 300), QPointF(std::nan(""), 10), QPointF(10, INFINITY)}) + QVERIFY(!ObjectSelection::select(pair, point, 0, false)); + QVERIFY(!ObjectSelection::select(discs({}), QPointF(200, 150), 0, false)); + // The point floors: the last half pixel is inside. + const QImage edges = discs({{QPointF(20, 150), 60}, {QPointF(380, 150), 60}}); + QVERIFY(inside(ObjectSelection::select(edges, QPointF(399.5, 150), 0, false), QPointF(390, 150))); + QVERIFY(inside(ObjectSelection::select(edges, QPointF(0, 150), 0, false), QPointF(10, 150))); + QVERIFY(!ObjectSelection::select(edges, QPointF(-0.5, 150), 0, false)); + // The cell row comes from the height. + const QImage low = discs({{QPointF(800, 1000), 150}}, QSize(1600, 1200)); + QVERIFY(inside(ObjectSelection::select(low, QPointF(800, 1000), 0, false), QPointF(800, 1000))); + // Smoothing keeps the object and drops the stairs. + const std::optional<QPainterPath> smooth = ObjectSelection::select(pair, QPointF(110, 150), 0, true); + QVERIFY(inside(smooth, QPointF(110, 150)) && !inside(smooth, QPointF(290, 150))); + QVERIFY(points(smooth.value()) < points(left.value())); + // Cutting corners shaves a convex outline a little. + const int rounded = covered(smooth, pair.size()); + QVERIFY2(rounded < area && rounded > 0.97 * area, qPrintable(QString::number(rounded))); +} + +void ObjectSelectionTests::theEdgeOffsetTightensAndLoosensTheOutline() +{ + const QImage disc = discs({{QPointF(200, 150), 80}}); + const int plain = covered(ObjectSelection::select(disc, QPointF(200, 150), 0, false), disc.size()); + const int tight = covered(ObjectSelection::select(disc, QPointF(200, 150), 4, false), disc.size()); + const int loose = covered(ObjectSelection::select(disc, QPointF(200, 150), -4, false), disc.size()); + // Square steps widen a round rim: 8 r k. + const double ring = 8 * 80 * 4; + QVERIFY2(std::abs(plain - tight - ring) < 0.05 * ring, qPrintable(QString::number(plain - tight))); + QVERIFY2(std::abs(loose - plain - ring - 4 * 4 * 4) < 0.05 * ring, qPrintable(QString::number(loose - plain))); +} + +void ObjectSelectionTests::theEdgeFollowsTheImageNotTheModelsGrid() +{ + // Grid cells five pixels wide; the image decides the edge. + const QSize size(1600, 1200); + const QImage disc = discs({{QPointF(800, 600), 320}}, size); + const QImage coverage = DocumentSelection{ObjectSelection::select(disc, QPointF(800, 600), 0, false).value(), false}.coverage(size.width(), size.height()); + int wrong = 0, outside = 0; + for (int y = 0; y < size.height(); ++y) { + for (int x = 0; x < size.width(); ++x) { + const bool truth = std::hypot(x + 0.5 - 800, y + 0.5 - 600) < 320; + const bool chosen = coverage.constScanLine(y)[x] >= 128; + wrong += truth != chosen; + outside += chosen && !truth; + } + } + // Measured: 190 wrong, 141 outside; the grid alone, 1682. + QVERIFY2(wrong < 230 && outside > 70 && wrong - outside < 100, qPrintable(QStringLiteral("%1 %2").arg(wrong).arg(outside))); +} + +void ObjectSelectionTests::theSessionSelectsCombinesAndNamesTheStep() +{ + const auto session = withDisc(); + bool done = false; + session->selectObject(QPointF(200, 150), SelectionMode::replace, [&done] { done = true; }); + QVERIFY(session->isProjectBusy()); + QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000)); + QVERIFY(!session->isProjectBusy()); + QCOMPARE(session->history.undoName(), QString("Object Selection")); + QCOMPARE(coverage(*session, 200, 150), 255); + QCOMPARE(coverage(*session, 10, 10), 0); + // A traced outline is kept as traced, antialiased as set. + QVERIFY(session->selection().value().antialiased); + // Add and Subtract combine with what is there. + session->applySelection(rectPath(QRectF(10, 10, 30, 30)), SelectionMode::replace, "Select"); + selectObject(*session, QPointF(200, 150), SelectionMode::add); + QCOMPARE(session->history.undoName(), QString("Object Selection")); + QCOMPARE(coverage(*session, 20, 20), 255); + QCOMPARE(coverage(*session, 200, 150), 255); + selectObject(*session, QPointF(200, 150), SelectionMode::subtract); + QCOMPARE(coverage(*session, 20, 20), 255); + QCOMPARE(coverage(*session, 200, 150), 0); + // Background adds nothing, and New there deselects. + const int steps = session->history.undoCount(); + selectObject(*session, QPointF(10, 290), SelectionMode::add); + QCOMPARE(session->history.undoCount(), steps); + QCOMPARE(coverage(*session, 20, 20), 255); + selectObject(*session, QPointF(10, 290), SelectionMode::replace); + QVERIFY(!session->selection()); + // The anti-alias setting reaches the selection. + const auto whole = [](const QPainterPath &path) { + for (int index = 0; index < path.elementCount(); ++index) { + const QPainterPath::Element element = path.elementAt(index); + if (element.x != std::round(element.x) || element.y != std::round(element.y)) + return false; + } + return true; + }; + selectObject(*session, QPointF(200, 150), SelectionMode::replace); + QVERIFY(!whole(session->selection().value().path)); + session->setSelectionAntialiased(false); + selectObject(*session, QPointF(200, 150), SelectionMode::replace); + QVERIFY(!session->selection().value().antialiased); + // Without anti-alias the traced pixel edges stay. + QVERIFY(whole(session->selection().value().path)); + // The document gone mid-run: the outline is dropped. + done = false; + session->selectObject(QPointF(200, 150), SelectionMode::replace, [&done] { done = true; }); + session->clearProject(); + QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000)); + QVERIFY(!session->document() && !session->isProjectBusy()); +} + +void ObjectSelectionTests::theSessionRefusesAndReadsTheLayerItIsTold() +{ + const auto session = withDisc(); + session->selectAll(); + const int steps = session->history.undoCount(); + for (const QPointF point : {QPointF(-1, 10), QPointF(400, 10), QPointF(10, 300), QPointF(std::nan(""), 10)}) { + selectObject(*session, point, SelectionMode::replace); + QCOMPARE(coverage(*session, 10, 10), 255); + } + session->setIsProjectBusy(true); + selectObject(*session, QPointF(200, 150), SelectionMode::replace); + session->setIsProjectBusy(false); + QVERIFY(session->beginSelectionMove()); + selectObject(*session, QPointF(200, 150), SelectionMode::replace); + session->endSelectionMove(); + QCOMPARE(coverage(*session, 10, 10), 255); + QCOMPARE(session->history.undoCount(), steps); + // This Layer reads a blank layer: nothing there. + session->addBlankLayer(); + ObjectSelectionSettings own = session->objectSelectionSettings(); + own.sampleAllLayers = false; + session->setObjectSelectionSettings(own); + selectObject(*session, QPointF(200, 150), SelectionMode::replace); + QVERIFY(!session->selection()); + own.sampleAllLayers = true; + session->setObjectSelectionSettings(own); + selectObject(*session, QPointF(200, 150), SelectionMode::replace); + QCOMPARE(coverage(*session, 200, 150), 255); + // The edge offset reaches the trace, clamped to ten. + const int plain = covered(session->selection().value().path, QSize(400, 300)); + own.edgeOffset = 50; + session->setObjectSelectionSettings(own); + selectObject(*session, QPointF(200, 150), SelectionMode::replace); + const int clamped = covered(session->selection().value().path, QSize(400, 300)); + QCOMPARE(session->objectSelectionSettings().edgeOffset, 50); + const QImage disc = discs({{QPointF(200, 150), 80}}); + QCOMPARE(clamped, covered(ObjectSelection::select(disc, QPointF(200, 150), 10, true), QSize(400, 300))); + QVERIFY(clamped < plain); + own.edgeOffset = -50; + session->setObjectSelectionSettings(own); + selectObject(*session, QPointF(200, 150), SelectionMode::replace); + QCOMPARE(covered(session->selection().value().path, QSize(400, 300)), covered(ObjectSelection::select(disc, QPointF(200, 150), -10, true), QSize(400, 300))); +} + +void ObjectSelectionTests::theModeSwitchesByTabAndWKeepsIt() +{ + EditorSession session; + session.createDocument(20, 20); + QCOMPARE(session.wandMode(), WandMode::wand); + QCOMPARE(rawValue(WandMode::wand), QString("Wand")); + QCOMPARE(rawValue(WandMode::object), QString("Object")); + QSignalSpy changed(&session, &EditorSession::changed); + session.setWandMode(WandMode::object); + QCOMPARE(changed.count(), 1); + ObjectSelectionSettings settings; + QVERIFY(settings.sampleAllLayers && settings.edgeOffset == 0); + settings.edgeOffset = -3; + session.setObjectSelectionSettings(settings); + QCOMPARE(changed.count(), 2); + QCOMPARE(session.objectSelectionSettings(), settings); + // Tab cycles the mode only under the Magic tool. + session.selectTool(NavigationTool::lasso); + session.cycleToolMode(); + QCOMPARE(session.wandMode(), WandMode::object); + session.pressWandKey(); + QCOMPARE(session.tool(), NavigationTool::wand); + QCOMPARE(session.wandMode(), WandMode::object); + session.cycleToolMode(); + QCOMPARE(session.wandMode(), WandMode::wand); + session.cycleToolMode(); + QCOMPARE(session.wandMode(), WandMode::object); + QCOMPARE(label(NavigationTool::wand), QString("Magic (W) · Tab switches Wand and Object")); + // The list's W picks the tool; a repeat changes nothing. + NativeLayerList list(session); + list.show(); + QVERIFY(QTest::qWaitForWindowExposed(&list)); + list.setFocus(); + QTRY_VERIFY(list.hasFocus()); + session.selectTool(NavigationTool::brush); + QKeyEvent repeat(QEvent::KeyPress, Qt::Key_W, Qt::NoModifier, QStringLiteral("w"), true); + QApplication::sendEvent(&list, &repeat); + QCOMPARE(session.tool(), NavigationTool::brush); + QTest::keyClick(&list, Qt::Key_W); + QCOMPARE(session.tool(), NavigationTool::wand); + QCOMPARE(session.wandMode(), WandMode::object); +} + +void ObjectSelectionTests::theCanvasClicksPicksAgainInsideAndShowsTheObjectCursor() +{ + Canvas shown; + EditorSession &session = shown.session; + const QImage image = discs({{QPointF(200, 150), 80}}); + session.insert(ImportedImage(image, image, QStringLiteral("Disc"))); + session.selectTool(NavigationTool::wand); + shown.canvas->synchronizeDisplay(); + const double ratio = shown.canvas->devicePixelRatio(); + shown.hover(QPointF(20, 20)); + QVERIFY(shown.shows(CanvasView::wandCursor(SelectionMode::replace, ratio))); + // Tab on the canvas switches to Object and its cursor. + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_Tab); + QCOMPARE(session.wandMode(), WandMode::object); + shown.hover(QPointF(20, 20)); + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::objectSelection, SelectionMode::replace, ratio))); + shown.hover(QPointF(20, 20), Qt::ShiftModifier); + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::objectSelection, SelectionMode::add, ratio))); + // A click traces the object under the document point. + session.zoom(2); + shown.canvas->synchronizeDisplay(); + shown.click(session.viewport.viewPoint(QPointF(260, 150), shown.documentSize())); + QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && session.selection().has_value(), 20000); + QCOMPARE(session.history.undoName(), QString("Object Selection")); + QCOMPARE(shown.coverage(200, 150), 255); + QCOMPARE(shown.coverage(20, 20), 0); + session.zoom(1); + shown.canvas->synchronizeDisplay(); + // A click inside a selection picks afresh, never deselects. + session.selectAll(); + shown.canvas->synchronizeDisplay(); + shown.click(QPointF(200, 150)); + QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && shown.coverage(20, 20) == 0, 20000); + QCOMPARE(shown.coverage(200, 150), 255); + QCOMPARE(session.history.undoName(), QString("Object Selection")); + // Shift adds the object to a box elsewhere. + session.applySelection(rectPath(QRectF(10, 10, 20, 20)), SelectionMode::replace, "Select"); + shown.canvas->synchronizeDisplay(); + shown.click(QPointF(200, 150), Qt::ShiftModifier); + QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && shown.coverage(200, 150) == 255, 20000); + QCOMPARE(shown.coverage(20, 20), 255); + // Wand mode's click matches colour again. + session.setWandMode(WandMode::wand); + session.deselect(); + shown.canvas->synchronizeDisplay(); + shown.click(QPointF(20, 20)); + QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && session.selection().has_value(), 20000); + QCOMPARE(session.history.undoName(), QString("Magic Wand")); + QCOMPARE(shown.coverage(20, 20), 255); + QCOMPARE(shown.coverage(200, 150), 0); + // W picks Magic, keeps the mode, and ignores a repeat. + session.setWandMode(WandMode::object); + session.selectTool(NavigationTool::brush); + QKeyEvent repeat(QEvent::KeyPress, Qt::Key_W, Qt::NoModifier, QStringLiteral("w"), true); + QApplication::sendEvent(shown.canvas, &repeat); + QCOMPARE(session.tool(), NavigationTool::brush); + QTest::keyClick(shown.canvas, Qt::Key_W); + QCOMPARE(session.tool(), NavigationTool::wand); + QCOMPARE(session.wandMode(), WandMode::object); +} + +QTEST_MAIN(ObjectSelectionTests) +#include "ObjectSelectionTests.moc" diff --git a/tests/SessionRecord.h b/tests/SessionRecord.h index ec97dfc..179892f 100644 --- a/tests/SessionRecord.h +++ b/tests/SessionRecord.h @@ -79,6 +79,8 @@ inline QStringList described(const EditorSession &session) .arg(int(session.selectionAntialiased())).arg(session.selectionExpandAmount()).arg(session.selectionContractAmount()); result << QStringLiteral("wand %1 %2 %3 %4").arg(session.wandSettings().tolerance).arg(int(session.wandSettings().sampleSize)) .arg(int(session.wandSettings().contiguous)).arg(int(session.wandSettings().sampleAllLayers)); + result << QStringLiteral("magic %1 object %2 %3").arg(int(session.wandMode())).arg(int(session.objectSelectionSettings().sampleAllLayers)) + .arg(session.objectSelectionSettings().edgeOffset); if (session.lassoDraft().has_value()) { const LassoDraft &draft = session.lassoDraft().value(); result << QStringLiteral("lasso %1 %2 points %3 cursor %4").arg(int(draft.kind)).arg(int(draft.mode)).arg(draft.points.size()) diff --git a/tests/SubjectRemovalFailureTests.cpp b/tests/SubjectRemovalFailureTests.cpp index 1d13859..01e83a8 100644 --- a/tests/SubjectRemovalFailureTests.cpp +++ b/tests/SubjectRemovalFailureTests.cpp @@ -1,4 +1,5 @@ #include "AddressSpaceLimit.h" +#include "Document/ObjectSelection.h" #include "Document/BrushStroke.h" #include "Document/SubjectRemoval.h" #include "IO/ImageExporter.h" @@ -14,6 +15,7 @@ private slots: void initTestCase() { mallopt(M_MMAP_THRESHOLD, 64 * 1024); } void aPreviewThatFindsNoMemoryIsARenderError(); void aModelRunThatFindsNoMemoryIsARenderError(); + void anObjectSelectionThatFindsNoMemoryIsARenderError(); }; namespace { @@ -51,5 +53,22 @@ void SubjectRemovalFailureTests::aModelRunThatFindsNoMemoryIsARenderError() QVERIFY_THROWS_EXCEPTION(ExportError, SubjectRemoval::run(fresh, FilterSettings())); } +void SubjectRemovalFailureTests::anObjectSelectionThatFindsNoMemoryIsARenderError() +{ + const QImage large = disc(6000, 4000); + // The model's mask is kept, so only the selection allocates. + QVERIFY(ObjectSelection::select(large, QPointF(3000, 2000), 0, false).has_value()); + malloc_trim(0); + { + // The model's grid finds no room. + const AddressSpaceLimit limit(16ll * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, ObjectSelection::select(large, QPointF(3000, 2000), 0, false)); + } + malloc_trim(0); + // The grid fits; the image-sized masks do not. + const AddressSpaceLimit limit(8ll * 1024 * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, ObjectSelection::select(large, QPointF(3000, 2000), 0, false)); +} + QTEST_GUILESS_MAIN(SubjectRemovalFailureTests) #include "SubjectRemovalFailureTests.moc" diff --git a/tests/SubjectRemovalModelTests.cpp b/tests/SubjectRemovalModelTests.cpp index f8849fb..72967e4 100644 --- a/tests/SubjectRemovalModelTests.cpp +++ b/tests/SubjectRemovalModelTests.cpp @@ -1,4 +1,6 @@ #include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "Document/ObjectSelection.h" #include "Document/SubjectRemoval.h" #include <QPainter> #include <QTemporaryDir> @@ -48,6 +50,23 @@ void SubjectRemovalModelTests::aModelThatFailsIsNamedAndTheNextUseLoadsAgain() said = failure(image); QVERIFY2(said.startsWith(QStringLiteral("Remove Background could not use its model: omaphoto/u2net.onnx is in no data folder (")), qPrintable(said)); QVERIFY2(said.contains(empty.path()), qPrintable(said)); + // Object mode passes the model's failure on unchanged. + try { + ObjectSelection::select(image, QPointF(100, 75), 0, true); + QFAIL("the object selection ran without a model"); + } catch (const SubjectRemovalError &error) { + QVERIFY(error.kind == SubjectRemovalError::Kind::model); + } + // The session reports it and keeps the selection. + EditorSession session; + session.createDocument(200, 150); + session.insert(ImportedImage(image, image, QStringLiteral("Disc"))); + session.selectAll(); + bool done = false; + session.selectObject(QPointF(100, 75), SelectionMode::replace, [&done] { done = true; }); + QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000)); + QVERIFY(session.brushError().value().startsWith(QStringLiteral("Remove Background could not use its model: "))); + QCOMPARE(session.selection().value().path.boundingRect(), QRectF(0, 0, 200, 150)); // Back in its folder, the next use loads it. if (folders.isEmpty()) qunsetenv("XDG_DATA_DIRS"); diff --git a/tests/ToolIconTests.cpp b/tests/ToolIconTests.cpp index 66ef7d5..181d8c7 100644 --- a/tests/ToolIconTests.cpp +++ b/tests/ToolIconTests.cpp @@ -67,9 +67,9 @@ void ToolIconTests::everyToolHasItsOwnIcon() icons << icon; } QCOMPARE(icons.size(), 15); - // The Marquee's, the Lasso's and the brush's kinds are icons. + // Marquee, Lasso, brush and Magic kinds are icons. for (const auto &[tool, kind] : {std::pair(NavigationTool::marquee, ToolIconKind::ellipse), std::pair(NavigationTool::lasso, ToolIconKind::polygonal), - std::pair(NavigationTool::brush, ToolIconKind::eraser)}) { + std::pair(NavigationTool::brush, ToolIconKind::eraser), std::pair(NavigationTool::wand, ToolIconKind::object)}) { const QImage icon = drawn(tool, Qt::white, 36, {0, 0}, kind); QVERIFY(inked(icon) > 60 && !icons.contains(icon)); icons << icon; @@ -77,6 +77,7 @@ void ToolIconTests::everyToolHasItsOwnIcon() // A kind another tool lacks leaves its icon alone. QCOMPARE(drawn(NavigationTool::brush, Qt::white, 36, {0, 0}, ToolIconKind::ellipse), drawn(NavigationTool::brush, Qt::white)); QCOMPARE(drawn(NavigationTool::move, Qt::white, 36, {0, 0}, ToolIconKind::eraser), drawn(NavigationTool::move, Qt::white)); + QCOMPARE(drawn(NavigationTool::lasso, Qt::white, 36, {0, 0}, ToolIconKind::object), drawn(NavigationTool::lasso, Qt::white)); } void ToolIconTests::anIconTakesTheColourItIsGivenAndNoOther() @@ -312,22 +313,22 @@ void ToolIconTests::anIconKeepsToTheCallersClip() void ToolIconTests::theDrawingsMatchTheirReference() { // Every stroke is pinned by the picture in the fixtures. - QImage sheet(18 * 44 + 8, 2 * 52, QImage::Format_ARGB32_Premultiplied); + QImage sheet(19 * 44 + 8, 2 * 52, QImage::Format_ARGB32_Premultiplied); sheet.fill(QColor(36, 36, 36)); QPainter painter(&sheet); for (int index = 0; index < 15; ++index) { ToolIcons::paint(painter, tools[index], QPointF(8 + index * 44, 8), 36, QColor(235, 235, 235)); ToolIcons::paint(painter, tools[index], QPointF(17 + index * 44, 66), 18, QColor(235, 235, 235)); } - // Three more: the ellipse Marquee, polygonal Lasso, the eraser. + // Four more: ellipse Marquee, polygonal Lasso, eraser, Object mode. for (const auto &[index, tool, kind] : {std::tuple(15, NavigationTool::marquee, ToolIconKind::ellipse), std::tuple(16, NavigationTool::lasso, ToolIconKind::polygonal), - std::tuple(17, NavigationTool::brush, ToolIconKind::eraser)}) { + std::tuple(17, NavigationTool::brush, ToolIconKind::eraser), std::tuple(18, NavigationTool::wand, ToolIconKind::object)}) { ToolIcons::paint(painter, tool, QPointF(8 + index * 44, 8), 36, QColor(235, 235, 235), kind); ToolIcons::paint(painter, tool, QPointF(17 + index * 44, 66), 18, QColor(235, 235, 235), kind); } painter.end(); // After a wanted change, look at this file; commit it. - QVERIFY(sheet.save(QStringLiteral("ToolIcons.png"))); + QVERIFY(sheet.save(QCoreApplication::applicationDirPath() + QStringLiteral("/ToolIcons.png"))); const QImage reference = QImage(QFINDTESTDATA("fixtures/ToolIcons.png")).convertToFormat(QImage::Format_ARGB32_Premultiplied); QCOMPARE(reference.size(), sheet.size()); // Type's letters take the system's sans; the sheet's is DejaVu. diff --git a/tests/fixtures/ToolIcons.png b/tests/fixtures/ToolIcons.png index 8c04ccafafd17d69eff1f75eb16ea9e6c2f89251..e74b6f3dc6474f06d4384ae99a35514b3555376b 100644 GIT binary patch literal 21162 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 literal 20050 zcmd_SX*`u}_%^yo5t)ZFWQ>FgAqg4FoOvo`NFfTP5Jl!GB0`7^88d`X$ULM_D3pv5 zDne1>J?`iI?|1L7dw<=}?-_b(S!><beO>2yOy?b@uXlo$nuD4|BGGDUs2P$-TX^wn z2dZuO_x8AZ%fx?NH7u@>NOaA_KV<Qubetp-FG*8P+1N8>KJ~_Fowd!jvxnZDV=~Ix zvz>MDG!s+lfQyyL`vKo)<&S=_F@<aG(+n(%(dNHze&6HNGpp;G9}F!A>}iU$nG6`e z%G4k8?pk}Dmz11$=t}D!c^>-cFDXZ^yczDQ_Br$Lca+_wO9#mPm3SGbD2V^$k`4@S z`=4JkPz4^O<X8INe}Lb+-N{QH_<z50$N#rKqOGLGhXr<T)z#H4zx<h-w6MBpcP}BK z%$f1Ouknya&5xnZq3ZO3`ETFINRRbWRqX8rU$HUvF=}gTccmWPY4!Zt*88WXWS<lk zHh&v_#<+WT)Q#WYOP%@t`}r<g7Z+MHGqbDn9~h^mraTu$$ts-(Z{ONjd))B%ON`!3 z;6e5iCr(gMP>@KCjg7A3&D#_9^XGED%h3&Z{CKY{*PXuK>imqhwzee~y17|TnH~-f z4mPjzIYP1?EVK9gIT_aJ(lam+{PgLeZ98_6?Ck6u9K;X*Ffla^{`>of05318_)M*> zbA4bp4JRk(`oG_wR~N>%4CnMX43-HVkKE-y_r7?dA%L=Qh@XYJH(Pt>u3fv7PMn|& z3JPj}^{OfL=yiQ#<K}#q&8@OrbmbKld@R%%1qC#_ckiB=oz=4XKKOL+N;RKqaPB8B z8smSr&c8|7Zf5qLgZKir--aq=eO6tY?kH2VPg6eZ4oOO4kBp4uWueBqkdf5Y)rCbw zRP^-dxD|au%gQ7Z_80N&mb*$KojZSC<e&MaOP3NQ?6#4thbvt|BO;9cNc7VlxF2y> z-~>5I@1f%Bnw{;<%@n?We+A)vlVx1Oum2bs>&-l&p{HlMMjC2u-PYUNE7=l~*x9M& z>E*>IEp4Kn&`%rP)|8|Bpr_)ZnUmG##y<lCgA7SdXJ_ZpA3x~#@86&G__2VfD9z=O z7a1eJS5~%?)YR1aYEJKDWo7;F!s`$d6Vt5AT*bxrv;PKXm%k0)FZE?~x-k0E-@MY9 z{rl(oXs@L&r80>Z`}0{XE1kn4BX@+VnKb2BRmooc{*j$W(Z{d0RzcDCPnO{WEa?3F zytqvx#qw-FjXZa8s|-FwLsz#2_v3C#iq7DZJ5)g`&VyxJPaoKM^!oSa0<&V*A0rAc zZf%~)zUJWQ*zoQh^ZE1VrTqE6{Vj_4{o`}f`}aG)e*LOdQhw7_ma8jSn&#c(laB|c zrl(a^Rkz5=$@SGpUmO_i$xvNUywhl8z`?<>u(A?#=MG(2S=pqV#g!{pG(EMqNJvOD zJo|hz;^x0U4Ie*p`6$*8e0$Z|8518Lgs(8(m2}AS&vKsDyt9KwTx#m>fhSfpB=Zt$ zve>vd_Gfn&HeIVvum1hp)X?B>dHv_4rl~XD&>y=QJCt|D_u#>UdjtjfgoL&Y4i1Wx zb_@?c*IOhr_KjYe?r!VLGcvJ`_o=F>dD~1$(s`)38+YHRW6{Ogx#ZF(xfFRXrn!Nl z%-3b%lz;mvXKfqYCfgZ@$~>RXK4d<)<SFmz?M<!}phh*eq#~xc>Z4e5?OS+muCS?^ zMuBP3!mESz=?0m{Ka|<iT$}%(Ttw;D8pWBZ87Cyd$}1!!gtf?iU8x<=HqkIJz*bpV zS>?4P#Lv%fzId;*=|ibq?-Q$|BqeKWUQ+st7hH!gk2JSL>~zBw-n)Na^b;LDJ-zIa zBNAoBLb}P;6RlAn>TeyBaT&^TziYYMaeQL7zkrI0$}cBJXrRy{;L@iWG7{T?(^RAt zHa=<^hO=kSMsi3~lg`nxoNGAm84(=3&3k1w=-RiT417PsXVuq#a6dUyYiU>e-~p$L zg}hzL{re#gZ~oDAYz%K2dx_nS^CG>C$)jO<X|dnz>{vl@-EF+|(&xIUqEpZKvHSB5 zasB%B%UFXsef9DsM@P*Yx)h;5V}ukxJi^H_uW(|eAXhFLUitBvr`CI=al9qMZK9Pn zdnhY2Q&lkKmFnML-|~94RhE{O#m>rYF1GR9O->HS2G8a`d+HSD#>R%M$1Dpgc@Fi) z@A}QL=wlnaW9DZ`TPW$&92|uAXGQO4v~_X{KOV(lAk%jo%d`0I<S{Z5u|O<GaeEoq z*n)8|4d=A9weO~--APH|Vqs-f7G`N|Z>Jg<80hKiqaZEM4>G&CxnUiZw6v%vu60CI zS04$Fh*%tNp&9z~?DFS<52o1ZxOL)EQq4c=H=kT3dz)vpH)%XGD~n?5)~!UvQC23S zrlHBJk1eL5rWSYT;nhhL_veV#OP1P>YSL0>|7zj)=UbvHo4hoR;{yBkZO63<i;6Zq zKKXF;+qbCpc8%j|p^fBfZ~y(Qwsv$3^YbGmCMQ2CEY!*QJv!5yX}C31R_(DI3U$e| zXJ+9gl2TG?N=hW$PLqk+*jOfc?`5i!4;5RUU;mNIX^xfXEq633cYL=kCnu-8;q?0_ zR;AeLSvfiUVq!b0tE(f2#VSt?UA*nh_iD-Q&$46uu<gZ*5uu?}vwXr#VXmtSHhcE& z6*zj769qQ_=dm@Khn_BEi@%azyr@}Yf4@myT1?H0NFCo`$3PW66&01-b6%w%O3KQ} z@4r%abrnxZO<nx{>1g)g==}T+lt8r;C$xRfCY(@ZE0gExA6xV!`pw5rpXTfjE7zj) zE-WmNNX{cKlDr2rnR#qKEC;qX;j$M$UH?o*N;`Jz4pvd3<`(*m+VSItV@h6LULPu5 zINzik35bo2<rt=+Am70$v*XbDE<b;NGNN*0Nj-1eC^#Ls;3>bp&_qwv{f&*kmpwf_ zdxShsrpbp{JUyR*?O1j5pIqJA7tYyDb`FmGt3Sx1+XAAa>8Dr^?%U`8{P|HqF_t4Y z&z~2%!*zuG*s)`?b?0quZ-4k;K0i{+NfZ*tUA|W1s$C)UJiC~2>S78D3pvuFySj8v zSG%i!>8g>Xh@chQzrWP}72E9bY`QIhH6>4<HoeW(&ihD5Lebl|u{uGI*KW12u<-D< z0xaly{d#fb{aHJ-Rfl&uw4`*i;xiwruk6J#?^-$#+Vo@xEiIZ7y_0V=x5DU`FUM6D z`gT~1+qHzVifl&ZM?}APA&-sOdw%AMyL(JZO61cE-L`8#ryP18ZKWV5lUGm}o0!Oy z8XkG!6@ZG9j{ZXJ(k;YAM;XDkU*%Q8elp{!AZ%>^rZH>m90uwjV&^|nk6_rfD_83B zj33#Sn6$JvTJCdZXMMAO+A-a0!s-%ThvXL&wDm=8ZSV8zmy^a-BPG?TLYn;J<5|zt z`B>NmpW)skAbw8Wu5TwZGjm{2kU_xDtgJm0l$4oORpwtVXlZFJ3}3DvTVGqE+P+=6 z{^q}z6N|(dW<PX3SIlR)r-#AD#>SW>5N{}?m!iS!5Fa0JOmSy&x^Cmo#duFz6aw`@ zGgH%T7cNY1^F0Qb(s+9-jkr^vaMy#w^p=*EqwdwNZf?XoGcYmTE-Vzw>AD~G0$)^& zkv~oT-p-({q2-jOA#}SJx{qy^Nc!c`;x7QHo=e8lZ&Fn+U3y}iEmCmr{7RxDU;{SK z817>vO+lUSU)1#5HU5)dzS#czvr^@=R;JRCiLbXh{qPp?X4Y+YcCUVUa9RAq8?Id( z94f*r_yKZ7Ma9THXH4mzGuZ*tUA%bF$s+l2`<pj+*sclcz6`Ct@%vJ(T3nV=zMe~) zQDC?FxxBbl*K5~uR2WI4=o2_KF<(<(wYL)uMCQ^*D!Pz>jemcenw!-e7d!FkXfTvt zx*r~V|Lf?rdFy|_ue-Xw9Bl~5n_?9;<vn<ip2Wb-eP?08m1Gic_QdLWPPq>c;9knn z>rYz$%%<(|imRCW>7()Uh>oO0SY@RQj)0+nkHH=$o1t<?&tLQG+6mHJn#0dM=7RU1 zs!RuLbvH3IN}xhHB~A`7N#H)CoIdS9;Vmd0Xh$ORBB*^4I}aw@_^5ySbfkI&o6S^b z{LJV2V>q+acm?3E+1IeIwhaPD*s5;)escQ#4d8b=Zh7Gq1$?y^o{P8dHgeX~)Ra&6 zfB*EHo$&(yU8?$jzc@G6mrhb_+h%CT1jNzY)@FyZ`Jwj4v$x?P!Xk>z8q9nI76AC1 zJkUFw?j4$xlw{n(zvy>7RZRo+l4v|%UO!MZGuyfJ<#mDC$Exy9jndLmokWdHT$g#N z?N&j>H>pQMQ2z$xZt&3qS5>)=kze{)<&QgIH`9~Rw{>$O`dGeL#xprU&UB_X*#=fG zNhSfN>IHTkOd3DAP8#%8M;6P4(eK>3bN$Dy&CDV+i~K`U3j;-`WZfsJh$5TrWS!p? z*m3sOKUPyy(_ND1G>V2(kKN*~-}qDTc0Ps@2S?1j#LrR@uRn0MPG|qwnycGSM02;` zR+AaOHvd%Tdure3hiLV4Bk2c&0Q=tA{u3-k)98GvWpcXKn-vIl;pb0seQ9B%J!08L zkM(9glnMYkv?S_|b4B9_HT3lvyHJFp>c_jL0TGOjS_e@U;d-g)=voifuao=w`V#Q+ z+O=zjs_&A{VJ}E6mO8!@njd`ncqHHwcXUV?^WO2t4{tW&!b-thwhhbP_~k4jBDi-i z<@0OboE8)XJ6^wT0ZSX5oD3X%dO_95h#A1q_4@S_-DwK({1kJdip|D5`Q*9jpPqmH zNTqD{_NlW?WTTz&qUG#Tl$4aff<~on&PK+@fhhC@%K3b2v-WMQ>B`DVZ|#li%X9DP z(fU?KqOlL9BRsyo&6cxz9Bzm`hs!yQHB&O#cSOiG`(&zIP)Uin^KfO9sn!7n1p*tY znw#&s$mf}#RX4(HlKy>s3+Z-1KweMAP|Wge=}GT6%DWnY+1mHVakLYvU*m8`;{VDl z>teg7?ZBY={!O4d8!jDul$p5*>PIpu{mjS5C$h4O0QX;3s;+%YKK(T1=cV6*hNl?A z7zK{Eo3!Yr4nDig!^F&-XRrA1pqay23k!C(gJ-&OSM@J6XE-UD^d0wYqibntDc3aX z_$Pw%q)PMPBiIAbTz@+ICMb;U_m9=1OKXWot_XE?b#a`tdv@uQ!~4OZii`2Fmkli} z!U8Di$3u8-rmIil_Gy3Fqw4FJyUMUM)<h|Gx++GbjzBfl$A)?7LpAm^hO>&2);D$a zcPsD_By)8k<L$3w*XGUFS0|!L`3Kgq=k~pz$C2E@CCi{>Vl{&@l3n#tL_~zBDQ9ZE z35>Z*)|EFhDvEP5r?{{%>-@&2j~|5(s-O@T`ASP3I#g=iaGQhAe7yTH7}EazQQkjr zq*rEX`Pq(@sg#`CeUf_*4NGo8`yVa<_wq}hH~{(=mY17f-9KpmT~*$1o6@T|LpJP8 zS<eMtY(t|78=r+y<)rVoOvb-|r=y{vA(?C`PEY5}qi+XDLV4g<ROI$i<VltDxQ&e= z<NP7rDQ^1?>vQMSf{tlqniQCpR92d<*5&Bwq{&wwAIZ}fZXSMixwp{bP~Jnvi0Fga z;g9r%v#19^=Lyb2JM=l}(1jA~266`{r$=bh=#^TprkTRj7JmP3YriWX;rv1Tc&EeW z#@`>V?(Qn!`Y)%K7<cWWI(+z<w@Lf>qG$O{wfhIG4F7noOm;9AT09M=-t2z$%5M4Z z1udV@ef#%k*PT-5cr8;U=wfd_b%9Ui-gf3g4`cID-bJ-^N5n)#8c~sQzg%HsV~cFr zt{vRdZsaI><Veyr2~mFQNp(YBlaR29F!fNmw7FR^MckeRU}QjGetCJYu(Jmk`7Joi z;x*sAJ<pP~tUqvnr1pj#ma^|>jmMl*s;k%P0^dOK*^^V1IuB%Mp)u?w3T^hVh$y9+ zX}qZV{r(^cyH+KXO#p`W*RR!&9}n)MR-<hh8A<yxmYbWa<mM)!n|M@Kwr`)3l$2Bt z_zsY*3VuNMflN?B0vpjKfU@}y9b&%z{iA^+vvxYJys6kPUtYrPa@6_P$(pQu_V)G* zOG|%ku@A0(eH%pmN$=#z%L~<}hK9FKJ-d_vvN5{oIrBC<!m$1%PA96`*z(tRk&%x( zU%g`F;0WJ+^qODybW>v^IUOCH?6vvC#=ptpHsn*v`}Xa-YcUw@D0lt4PVM<JXStB3 zhGglB))y|ED7qNBit|K8Llcme#!YHZ-6Rv|RU+}LBr3UItE(J5n-e1+yc+z5BKC}5 zjXe46fdAk6)@fcv&?8zBzHQv8K#dXExe1r`gS}O5c9u8a;Z9A81g*{$*Aw)J=z64= zpFi^$<>)lDMf1d;pS#}-isbl8P*AYSdqre?VuF3C^@=Rl?8ZJJLBXxS?Z#>Jq#Ybm zRN%}BM}&7vIWQ#bKV>@IWq<RgV&&z}PxRv-1dBVq-IKgZd<sD=P>PJ^9!Ot2zEV!w zIO`*P^n&syadGkVGt-lk;$<g0{>{wI9V+Xtv6hTn!E38L7x{x}Ik%<H%9|PcNjddV z9lG!)E8&o&<h_B$bh+3x-Nh?q8BhXN*VcN|GO&EKOgy5dg-XCwxgIzx_x~<M3SJDw zhMNw|JrujQLf&nGR{iHL9iiT^TH3vODY+goqTE~uEy^gg6U6csc?G2+yET1CCLK99 zZ{F<F)fZFAKTGrJ`SrFcw~5@TgN_MC9K=`H#smEb_abhN?*LO+iO-sQXFy+>z4lOm zsYU1&rTpou#%B*+d|s2#&kB|T`G|n7iN|g!3V0nxCl!~Jd{hL%gX>M2g7&G$+1WwL z0oyu0MkgdBoVH>1_V!*_Tr|*pGMuFuCw_TE-pN8tQPF4B545BAd`6H8UuS2hK7i$| zTept4oX9*L)dT$f;ll^<Gm`f;_xyb-F~yqToSR=z@Ty(W<TVt4y!Uu#>a*ATmmWL2 zxJbARNo7x)glQ{8*feh8r+c1%rnSmrZovJ1@_jWi#pXD35(5uUvHly@%uc0GYVEl6 z5@0`2j0Z9f1}7i_ovhvXGglnXF?@i9nji;8ujsku^@mEc3{Fvg2kX=F)s5ltVB&f6 z=8Z=<y@W>?1LT>&z(D=On?Qr{{kdi*hMrxHMq@U71z1DvVRKMSEXzbAS=Q~5O6uqB zWk*~`N!W01qc6$U*QOu#9eF+FE&)hic(%T-LD#q7^D)RZu3z5@eys7JDLz}DQ@gjD z;v}%yfzwrfxLtPO4IiFfpa&gu>WYMzW|gOV{vx>C&A;Cc4>hwt>^^brnoMT(fiGXa ztl|O~nVAEy0%NO-6T2jADeV4UVCtAl%PS3-T(i*Ef7z}m2$rbD(_Zhh#soZx+tZM1 zkjauZMbNVQ_jmqWTFUbPvB_aw*WU(SeDD3&iMD9kU&&gp))=C<LKMu>`_&%|WMbDw z=lrS0gzxz-v#qmxRF;>QtuI}o14<N8)V1;giZw~iN3X((&9s$E3WQSA-5s6$y87zZ zSgpC{_x=7yj~rpgx&nA_j9+-h8U$L@9Ll%~;j>@G=flv@+u58gG5Zvo+nM;RYOXdA zcYkmL{ieCOS-5+(+mGkg`rVqx=k4wLMeEkUYWtd^@FD&E@dljse6N}e-v25~=qEIQ z=GInzNlB(NXU@1@z1n<d_tD&?q0UZf$V~aq_GUq4D!+M9slWDVRn;zldjfubt4&k5 z(aB{^@P8nsMr<4g1_r+<&cn7K93M(-w)pt?jDG#9Ih@uu(QlDZQ>T+6TdKE66eKX| z0jk~Kdos503kcvhCbkVXOu3gj4@#Ch^xn4I%Rp6f^{e4f%^%()Gv}R12fnXtNe?zF ztkd{D-Ctl9Id2qux&9y9{ex#)0X}x+weE{>8Ls5pw=ZwRqySK43xRTRqx)(yR73a= z9H47$ZPiKAR6RXw;j<n*d8R{4_H5a??eVe)M8}ml%9>9&I~O+cU8NQ!_lk|=<W~U^ z=Gwh`Y-VP4Fz}Mq3r|L6Wo0UA>PDdLx!Q-|8~PF-2qg$=14QH`N#>!UA<)K*d>7yK zRmM}5&Nj}Q9(+H#d?07m=3Du8Bqk*h3ZeG>1KII&cF&u<JSLM;<AhXXE|2KY%0eP4 zb?D{K{<W!peW=n!o0H-=)@P2-#K6EGx5mi}2;X72a=(BK)5gO`kN61<_51hlea`t{ zqhGp`jEeu!a><soDxE4@v|AAuJ8)n0%C8Nuv2sl^kRNCuQWpnypwP5;bmU2pz5%b- z&OeF5fovQeeqi}T*GHqZO*>)c+d$ESE|DAIJ*9RhIyqOryymp@S_@9tVEe6Cuu;RO zL1$Q0RKzKN^dJLO4}dPo<aa2_n$(BZSrqaB^thg$n1rTADV2<phKbzub)T*@g$Dz3 z9bH{JP@Nx78Hoo?u6^oRC;)KLD!S&eIIa$YE4)V~k%dP|fg-eOTGgs{_pGQ&v+e{r zIl1-a%f-1~g$oN~swoOL*e-OZxx^HRK^P(<BkPMghegrQ(0B>;*FJ&s9_3=BiHZuj zv_oEQu3vq9eP=R5zL9y)t?gjZ_!^w}qJ_O`rRC);+qZ8Q*}MsA5CzQ%*omCvV$H?P zt*31s7i1n7%6MZ7tdOy}xq{em-PtYxsl35=qGnH|gqJ75(loTR$QL%xNZDsNd=Gh1 z&HIq%_xDc$#l;5&pQNUw%=Txdduy0zYHnZQBsbaecDCBLLg6HEJ<a9Im-p=1GYZa5 zoE^PXIrXU}lanf!99GReW<?brJ$h7vEzl~;tf_bz)NFKaPTTik?3$!)cR&`Uilo=6 z^(1;8#nFF%e@6}pYZ@3Z8X6gqk&=^>jmJHF*=3w}d2H(G>Y8X(D4VQ2cDr(=srt&a z>saGfOSf(6`=Is#iUW*jS=9sjv;bv{&dzqu1XiEh2+5jOH}GDbQC@N~E4~(=m?->= z%Er=%w32(<<dmk7<FJV3Zf(g*<0QA7*-khG_+}B{98eJ&A&O*VWRUXpnjhZUU<a%9 zpcAP+eL(K&j9+p621io}{o`uti12Vi@PIJ`36kHDhLNXBfQ7mgO%y#$!xysF!(Z0M zTcKAIn(fMh_u}~0+qZ8saB)R~!eLhl$jhI8q9y&7+q<r=F7hx-e}8{aW~N<g{FyyV z`<tPu*S?8~2L2su4r7jNF#u+<T-EheDbBw#|3PB5K@uj_HU(p<iYvdrTg`uZzVpC= z1B4di;n5N+s6DyyqOR^{)Wr>8TIu}Lsn};Iw|!+{VcP-ABI`Fd9#npU!ZZriO255$ z_0Rd++nI&>PWjA#uIEuQdD;jSweiD;#Jh8R0Fj^rz=Gn^((0e<d>L`oSZsqW({^ol z4AQ*aPKts-um2jp+;<bZTimvp>U90hRBE!$uC6S1@ucNt9(hj&XuYI-t)_>^Zq=Q! zZ>*@Oa17dZuYX!Y@?==YQl8v6#YH!ZufguKef#8ZuJ0Nf8zY=A*70R%<TfCH9@M)2 zN+bmZ1*yv;TsZMP@7}3GS&K<cjS3H^zB=2t9X*hg-`dk(Y^ENw>;qPA;#478O0^wO z6RoUI<X$HO4@uSD^J2ig8IzRB$#YdzRlK~seohA{jH{>MGx-0H<_osmL}<PbA|cNZ z5()?z;I)=V6WA}bULE`FN$hOYQfa&E_`dFG#;UO5z@sEDoP-GoNzsAc&4lO>sn;Q@ zLQzsePlrT^{w(l}>g!O2DCBgtQ_sNfTU$cv>J$MIIPVDv=_VRq_&M2uTWG&0!4mK3 zGE`2c&w9M0`MNtaejn6e@H}z2G<WXYF=RSfFzq5d^jX#F?@i%Oa105LS>uH@AA+aD zmHj?KD{wqAS7KPsbHQ4q+%&SB{rrs^H-`LLVaL(zJdhPe4^)r(HSzV`<34Nq_87j% z>$cnSn?tI2XnE^bLn9-b{kKp${IxCn(~n*5Hq*m5JM`=4&&J7)xYCR74`BUBxql8^ zheM;FUGTe{JU=!aY(w+Z-Xqp_hLd~u?wvqgnSu4VW5*6++oQ{fvLtsve`I1((QSot zF0y~8(a$sHr{XJv`Ms>f<6pVMHzV96uu(#_ATwsAN8{jR+i$6+o>1=8PmT5f*m)ak zhO-_FWu?P61?uoNg{GE_*tNAaSPn5sfoC63L4b`iY^F>nCnGP}t8%9Prmo5T{4=a- zp9q!Y*SBFua<xb$Ucc;Y!J+_9tB-ZQoFF~wa4X`@4``{%^Nj1uOZ@cyR7XJw-!n5a zma2z;%?~y?Eb)8c24u*wMX2#Z5H_HnMz`+VuhKlO>aJhzwRC=ds3OAc!Cu|utXOhZ z?9@alM<#*YZGs9--4Blyn_d4rdoMnIY;9?}uj>@>T|5kN6F4YiC<-N|rEsH_pfOC+ ziQY5q4R4$1m}=N1cU2f=G}pg9T{*z%-ssBGQq$C{`;K#j!d~rcL?P4?A{iPL)e^&} znrksDlohx4|H=ZOAK5kq)6TqmOfM!Tmb<>yME6U7kj5SrH8qOy@$nwe`(^k@=mKc@ zC8yIE-0tMcs#O*i?f@AwP2RhVUVHC;Slh(DsplY?Sx+K<{&csj=nXd1*C)Xsgz#@Y z+m}bmPxG%||49R5@orL5*jQ7@Vd2=vd`DVH4xc)trKK;fkTjY3I1gWHhF<8%ZI*-k z!jJnRpmK*>Aw2U$v}E1q*<I}H833=O{B0CE(&YJP<f`D*e9zwpn%dsg)eLy#_G{iE z{=TS-!-xL++~Ip^AQwY;GB-DG|0pCX8ew|I#6;uGC78EW!KBy|f$dB@{TCRMROU7H zx9XVa+>NZi_4n6)fxm@y2XpM!TiRy>&+N=!@M*Vug*uok6hKMGl`gm-+6l;;NBR84 zZ&_{aSKH#X*r<aDRw-SQck?4kjiupX{rIb&Vz|^C0e<o-R!M&2<F@c(2$_oH_oMDF z9SE7<!-qVuV(zA<eo9~Uv0s1=0YtzHAPV^ny{-+K-)*?fkn4`;-`C}3um-Mly>cZp zDC6gv4<QQxpc;0rqOM<uw#5Mx8ah^dtbl+;%qUJ(Bv0m}N8~t_ZFn<A1_mXs!!W5? z^z=5DzcLg0DL};-L_p}?mlpPnL!|=yrbHY7YDiA2v}x)re{*|#s3~of<|0|Hz8`7x zldBl2Lsz227A2F8XZUKze+|QN7;^VmEWNdP^W23CMo$*OurqRVsj!jTaMx!02V=K$ za9p!@a0umA@YbKZu{=X|`0!x}_BR*D%#n>KSZV!*ONUbs9sl{vTLFPrE;yR|L_`ep zR`sQI1g+}4mV&)spR4E9?B(;?(YPrv7*nLUxDu;z^UMfYoz#yuz+s`Mn}p4pqm!g^ zYI<~3X?x6U03!rZR6HXa-~E>5Q0mi9SMF2shz^$@pMZjBYN>czj1ZOq?;Bvb!k0&j zC6rL?a;<Orf%opQz{nDSZ$}6wC?@Oc>xAM&@USCCjtGm11;T6x-yz_jC0+2O+B7%@ znUW)a^ZWN^j@)dJ3ZI|hoYl>Z0$mzkoM=OjHz~4@5fv46gMUa+haaEoLqb9*S59Mh z9S`{HYrI!RhRyfSG8x=FoNxwq_I>xMwgY_soO<P$k){(tuhc?Lk$Wo8=Z-MbyT?>- z-DM%?<-PR<1{Yae5GIeaF|#c!Dsp5^yT^J*knL_budc93$iA_2@Xp}7b@*&{#O+11 zNtf0&6pWP^uP`wK5{HRxWOvjgl-?xVBIk~vg?Ty@IW%+Hd}exMT6w|0BEp&9kLh2h z3l-}V+^HrhR+N#gAb9Lx@!mQ3M%nwJLDaly$N(VUfBG5hP=|p+_K`Z@2ngCDK5AWn zUw7}`^(T7p_m4?;>p1!uL!8K80)g}yLV&Q*y*N<V06vwTk>P*9s^(>H@7)B4C&IDJ zVx676+M1dTk?e;EOc>;L(aC8bv9D0i$w|bt_)HW`;k4OA1@C1C2qT53t78}cW=qmo zg{tv)Jv_#vr>6%UpQ?zzK(f8F^L9ajC^W4g!k12qL>q^cO~eHNhwwK_V-VpvD!zUD zcGpfGrFhD7a9}}x$w<{|RLHv^>Y?icZ&oy0JgjpomHu-o#tWZr>1oitNKEkpYI?%x zWM^xuQhRA;2iLpxo%vS%CjvwB4&2Ls$1v7B=8BDTn`3dvHcj4({N%}#1I?8BQ~u2F z;Qd&tG8RO{%;t73xgo;B(eb=fo{gD>jcptiQvA@N0Da-W>}>m@*Sn`Ou1vq_^PR`< zQ+pV1#>PY%T!z#V@Jo<gOYrjijg_7%yrAFf@;|sC4&>m|HZU?jK;6ct1f#I(_s2E= z(AFzVQS{}2ACseSgX1<#+m+Lz2M<0ru`}Mg$oB=i1H2+#gxbs7o1dVRXH?uwgp{hQ z^9YywWAzoA3l~BF0tsu>a48V7W(*7*ln#DybvQ!k5)>q@z|c(*)j;ErrZI7uo+5;) zV22RUAU;w#JUl#0F9(q#!_SV`HlyR?x1nR|{}5eThwdYe8b$;u(yuY{7Y+5}%rJ6u zKVd0t4=0hGi(s&qN`Kea*NlP~bMIcWC+qH0r%nxpEsr*AY5i3Tprod*-a8OhyPxOS zP2+-QXCc6p;z(HvZTUp3`3Xx)jwwxRCJ#;C!U*WyuTpAjeE-U<|NCcguzX>rS75dx zHPtG$-w2**7<vZbidU2rfa<=4F(@-?u<h2rm7STTbJN`qAFGU{q@-|&DZx5AS)1VO z&?Z&pt~6N`+;WYz5IKr__wFIi_Ht;519b3=0u#LY{+XhztgMl0iVJU2@~%8Uld}U^ zFTdE;&_ISb#uk#;nHReNxu9?eI1J+rtbuJpqodnU!shHBN;L~Q_~%!xqmM{AzNJ8o z$k2KdVROnSbPx`8Fg?#5(;`bEVbR@PJTj<IGux{U$3(njuV$&@_=Zq0Z0`#qm-) zdzJ@2p0Kd+OCp2>f0bjp#Pem>nCs}w43&~Jf8eVrTYMuzkwip_<=1>C(?RExepnWE z<j~rn)RFwyIu?FTX%kL+UfZ#&GYq6Y5tVx;PS0e_nR)%LGBz4t(3b$%hA!Zrm$$F( z@AsozN3ZSpF;Yt;FJkWAHOLgf3cW-&<Dkdc{%6y!vWbT;b62e6HsrH2Fn_Fa+XB^9 znrZ9izu#vAgM<0eyG!k)DRSg_VinGRgq=Yan~>0Ien!`0kNQd97@>RlO@Xe-@?P#y z9)$l|d*d*$%dWh@K79a=v8k!8=}t;F0M0DqX5iuzV#`qa1#};+yla9H(LBc*o0_(u zkmy@V7lONIWoJvv>S)D&zWJ9HhZ{kOU5Y-kbi3t@FEhSH+-wJrB9~Hs`VvGm+REY= zFH&UQ{XLt3SPG5>0kL2<NZ5C>K&wz6`xmOcKyET+H?_Y(^zz%fJpiEP%IY34-3c+( zd8m9l6ia-9Cx8cbgzBImx2%MJ7D)n8ldD&yz)lUCo);V#-;(?14!$KqiM0P?H;;^* z87oNxCd`EHPReimo><svyc1bG>i9mKIyFyESyGQ0k^sv1)kJ_dn2{LdUpN*eB_%JB zCSc&;5V`(z5=ghHyPF;cCGBi#h8O~hh}6C3=+_46?wWb3%vPq>-WWt<U}+f+%g!>+ z+8%vKom@?_+MMIy*}AMLnNS;kM<XMnxG!=E%cf9Ip9hm1`*QDgiKomCN8H%o+|r`F zhlx*2EOOC*6qsE2KBPA8n}5FUZOwO<@Gon8vPXfrbJ<Y%t+^3LK7-buKYz+YWb~!4 z0Pp49tLps&!o!T9S;no+jZs%wBEv$yWs4iqV;sZRevIsf%Li$lw6MARP0}y@PE~bv zxIspTlp)Sv`~GqJ_3Mypc7JIJ;$UfjO2cuEWJ14NsBsl7=Xm^bd{&09(&VC#@N<%? zkB|H|S`G?;M#pzKd=e6j&^O}#F5w0lkEkOoN=W@;My&kDj-CB_s1!}N+_kvUW%v;U zIO$3w6i~u0fV$Fx2oTAbb`obMuO}<>oJkH!bKbepL58d_hQ+fv>S!sCS^G=M%bQW0 zU7-azf2=Y%?^&0T!AC3$VKu-m^Y`~hY{Dk%D{mb5fK5lNAYr(<yp0NLP5Q~N8^{ID z43n^YyDWM)aYszu-K;cj`0eWiBpmzx{m%c6Gs$?~>aV`FsYqniZf&el0-@RaY{Ejg zx3l@>OTSscm!?6Yn)i~H_`XV5%gxVTQI${M`TX7QZ^I$w<x&;%#=D^z!&y}3Wk^SI z;6s(001^qeL5~R<j-YG^kyMJ}JfSOI!l#Im*zK@&9}x&`M6cp}@lfH$uXMBzq?B&M z(7}2ILqq9Q;4LdHRfdW<mDk?hZY|a)g!Q&-jm*0qYN)EOwxcfu(VlTAT!}D@5dTBN zr{&?{slIa&IW4GHoqvgzVmUK@Yqz6sQS-<8TRdT5VH!F*#sy)lnn>o7Beg_ib;<on z7vDb#did~cYT8yD9Z*>!HbC-2%CaD8p^&DOYRpx(@99WyFC!OSNbmZaj~`F}7q~=l z7qL5hHvT5e85kV6{@%(TpPnDSxv@6Q;k6%PCG0PnOCv8jLalUuBD?1Np~OF&CAYB9 z*pgAef=liy75X9#n;7Z-tD?JRY&<;C$nfQ#^J$PceAw)d0kqaRL8Uu9$KIIRi$Sl0 zMI7P8OVPI@kDP_3!I1T{&k}9?OA6!H#&D;wB@G;{w5A%+G~nW7BpiDdctr@Dz({NR zc}<|-+gNA5BS+Srwp)}|RJ1_Y$u@N>(1Ruidc5a4vrG<=WVF+)2q%tItBU2-t5+E~ zIko@RLyp-^NzbhY@gS;)8&?XnZrvETo#2J9bATNs5`3N%6-5OGQlL8aj{mVYskIfM z%mT%L#AD*E$nvne4U3ffF$4KuLTkb~h!?3s>&wwg)uO5GY;S*w&fRA#scAw4XhEg3 zg?OHvt-k}%z`Ji>nCU0oR0(l$B^13PDrG}MCc+j0*B^Qr^=j%7ZXq;}Y_FTp$6P`3 zOp-rY8(W-fg}w@b+PF@~!NDOatQ}<3Ze_N=+ygd^l7mC;5Vg4k>VAe$Gmdh^%G~>c zkHHZ5ZE+T6`tq1TSQjAmenenJ*=~or=*@gJzdqL6>W4y`{1Hb)wvdsLF@vr2+pN;c zlTzE8t7DV{?#g?%w%-?4B=u9pbl*h|0NLHA10R;)_rQ4=1%whQFAU!VWXV?pn4)s= z+hR-`{!r0ZEB+4jJM{-LE>6qLHPHPN4p?=1PKjcl7M^-WM+DMw?LbKP4k)1r$Hc_E zoY_=mL)Mp22ccl(dVuJ{zhdJ)b~6%4z~SP>OgLpTBeh4r-RQ>pA}dzc?qmmacbpKu zl9ThV#dA%mfBaAc^}T!rJIHfm{Ro5(THIii{r3yy$JNzs06g=h?n`8a#XvI<&2BCN zmEz*!5_cU{LP+d=m8qP^Y~bpuC$h1GTtgrOy)^kXi%9lw^YbOwzFCq?yh4dxQ2p3E z_K)25EJijqq3h<)wNy$tI4+#b6?vC{yzy2VwjPeU(hi(I$KraaMdcV>I8*RRtU*qY z<ZA5eyGPyTwLHU!mc6+A)u{6I`bm9r^HBWY=<^?S0YLCmhSN)H6~U=&EG+*eyWn%g zGexAu#@>eH+Xzn=1YOD7TMo^ixUZ3%hv}3|1SCnJp`j#_Q*GkxtNx%iR3B>>mnb6I zi_DP-GmldLZ$;$85G<esX&@NE(i(J@47n7u$7N+q$fY&@S(&RB?he^Ao5))zJpOs) z^=n$Z^xiWwz&vCQXAj98JH`V+xNx#sBB;Dkx$_ZX#jt|nk_15TqmEqpGK#IK_4Uyc z9aQ||@rLi;z9n3vjYG88S(OF38OX1I!w}h1RA(^q^F>^O8Ppv~Th#9z^S6Rgbn%;c zfsVVoJG|whd%lM+ecS~{eV4d333Tsb^9eI!V_u|aPyuD##wiqUuII8G-P1cT&`N)7 z-S2YU+BUdAk~aqXLgwe4PXo!}nhY(M)Vv6afpx{@?Ui2EKdm6DsCedElPXm>k-hK% z`hrQQ7S(wfH4F!+3HQ!zebuc(JMzgvFlK=hcPNyEc;+HNkI<3Hj7`))G`KEKhwvL{ zRb(XanNu|0VqFZP@w<Cznu6R26CW5#Aesm%KRFGptHbJNe7Y<u^rsq+05rdT7x}}7 zcM?E*ZDex-D*^=2fY30se$UMfg|eql>)XYTs(gZkxei~}SWp8BQ~b0_87(94e`6*9 zQn#tuVQeOdS=>e*6GCBXzdVD?|6{;JUjmJL6)}YicjddK3a_caar@stTc@V@UdvVI zrTsAJ|4gndOes-N{Wt$XyhfiLGbI0KZY5s%{}+G6;mnEo9Zu+!BtL-Tg#CQEoUrzZ zSS5)BWfMJc8^&meWqBz^SS%$rP5Q9!5FZ$Y4-&}}K+FQ!`V>>dXA~9D3J3^*p(9a9 zj#!oRgDi2h9FiX}c$u>%D`q%Ie#GE~^Y*92fk;Xk1BhS<>-$l5xi6Oy0ju<af(V45 z_zoYw?-J*Ll)lYaV^FyVxJ)ZvX;GGY3936no`jJXx%(&^=z#MBwcf`kd5|q)$Nzy5 zAqlMN?k@SQco*btpxpoAT4vQnNT)-^IgvUHNT<7Xz-JJ%F}82BG%>XTSh;ohOAo^9 zY6v1MAZES#=MPBo_KuDY=LhPlAYUUR+*p_%9BoKGt{dxK7(sdJl6;!*9(0qXl)b%c zhW>59?jcI{RS%Dh%*-ti+z5vfRlWz!0hpGI1UZ&)7tAY7WnfDXcx!oOrD4o<VSVii z;pY)^K+Cd`-b$uVPQ$yPA<~(Zb+GqPfSitGc1<i2B^7E18Hor}fVF`<FhZ&#k%}xU z8gYpG$H_2ba0k6A6PUWEw>P8K43!qC;Q8Ta^hiXDS!fV81BpmtX!_nFbW0M@SNxzc zC(fetgO_0Hl&GFfFrF5h=#S;TuI9_ZTbaM`l$i1*_rtIXp|Z8&vn|RCsez~gA^OdK z0F*)%%y02k=3w5drv?dtL}C`wd5Pj^QJEW#P*VEk&voWiu6#%kxqTawMs{x}$rZo& zFMfF)zxXgXI0i|n?{c~m<6Fc*s;aB|t9LW<cB&)X#3|$KhZ!Qv3a6l+9{mcAz&B8W zkV0z45JM>Z@^L7)gyoKK2r&{d-~yLP9f^J{@Zz79i!XftvVjKr#l<lLlHHk^ai~aX z?tAwxvo(7B;wFA}_3w{*q_~lOXOelng(QVKOnfBdK~nnvj3tPPsoB`@p;UvT#>Ds` z@;eG}?3^sdTDpq)u2!g7J-G$~kb?zai>IZfMTRpR-x`VpH<)R|#NxvI;31*wMJ(Is zxTxTqqG0OJA&Ekd(KC`?W~RXTu0-Y9`_RyV<xfmq3-85-L=HKaZg)801mZ$pV)Yln z&q+!!4|;QSId^c&6YWA4IUE&LRS>z(@LJqIq$d1Pxk;lC(pmu;oVx0eg??%?vNIrS zRTjY%OmnqYYDy7#fFD1iu_Yld(8I1GbTxvf;2eXTm$~p`H>~>Hj9NjM6a-Do-1soo zJP>n2gzf+v9{LMVBW%Jrmv`b`KhJ6TkkSK#;W*!A;j;xk><r%-_>vAu3Kjgruybb! zRvu@cj09zaj#L=I0Nf7bA%G@};w_zafe3tH6D-zm{)_I9(S;BM@n;O{y!v@_BLo8{ zFzCf8DJhAuK4KhbdHI}`a2S@V8EIJwD28yRMv+}@A@0W03nzz^&hCZ?yAAtbY<fBb z+$L}02@;GxEB$6jKd2ZRvk;tz0Fb)NAz7=53O8g{3K8fv45zX&_9LnpRC^Ft0vy4K z>67eJ)q%Ex@wig=DJ^;5KP)5%w@Z-ym7qgmxe(8^@O8!f1l0Cz1YpzGXF#NfcS(qt z7$I&LMpew8U)z825Cvk*O3uz=6<nO0G(@^%srwjFV=,nz)Ff<!_JJrsx#Zd2a! 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 F{$G=?L(c#J