diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0f7cc5e..e746b88 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -29,7 +29,7 @@ body: attributes: label: App version description: Release version or commit hash. - placeholder: v1.2.0 + placeholder: v1.3.0 - type: dropdown id: os attributes: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec91663..17d2138 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,10 +12,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: 8.0.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0cd736d..02f788b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,10 +14,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: 8.0.x @@ -29,7 +29,7 @@ jobs: run: | $tag = $env:RELEASE_TAG if ($tag -notmatch '^v(?\d+\.\d+\.\d+)$') { - throw "Release tag must use full semantic versioning (example: v1.2.0)." + throw "Release tag must use vMAJOR.MINOR.PATCH (example: v1.3.0)." } "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append diff --git a/.gitignore b/.gitignore index 8365549..2658b31 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ coverage/ .DS_Store Thumbs.db Desktop.ini +/NUL # Environment and secrets .env diff --git a/README.md b/README.md index cb6fc73..7765970 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,13 @@ Turn screenshots and images into clean, transparent PNG cut-outs. Photo Cutter is a privacy-friendly, portable Windows app. It runs as one file, needs no account, and keeps image processing on your computer. +The current stable release is designed for Windows 10 and 11 and focuses on accurate, repeatable cut-outs without changing source-image resolution. + ![Photo Cutter editor showing a cut-out workflow](docs/screenshots/photo-cutter-main.png) ## Make your first cut-out -1. Select **Open image** and choose a PNG, JPG, BMP, GIF, or WEBP file. +1. Select **Open** and choose a PNG, JPG, BMP, GIF, or WEBP file. 2. Use **Draw Around**, **Click Around**, or **Shape Cut** to outline what you want to keep. 3. Select **Save Cut-out**, give it a useful name, then export it as a transparent PNG. @@ -26,11 +28,13 @@ The labels are deliberately plain English. **Draw Around** is the freehand tool, - Cut with freehand drawing, connected points, or ready-made shapes. - Zoom without changing the source image or exported resolution. - Use a 5x–10x magnifier and choose the cutting-line colour for close edge work. +- Choose Precise, Balanced, or Direct freehand response and Low, Medium, or High line smoothing; hold **Shift** temporarily for extra precision. - Switch between smooth enlarged viewing and exact-pixel viewing. - Save an exact-size reusable shape, rename it, place copies, move them, and commit each copy as a new cut-out. - Rename and reorder committed cut-outs before exporting. - Refine edges, compare previews, and review results in the Cut-out Gallery. - Save a workspace and continue later without losing your cut-outs or saved shapes. +- Export selected or all cut-outs with visible progress and a safe Cancel option; completed PNG files are retained. - Use the complete interface in dark or light appearance. ## Download and requirements @@ -42,6 +46,19 @@ The labels are deliberately plain English. **Draw Around** is the freehand tool, Download the latest release, place `PhotoCutter.exe` wherever you prefer, and open it. The app is currently unsigned, so Windows SmartScreen may ask you to confirm the first launch. +## View settings and workspace data + +**View Settings** contains the magnifier level, cutting-line colour, enlarged-preview smoothing, freehand response, freehand smoothing, and light appearance. These preferences are stored locally for the current Windows user. + +Workspace files use the `.iusproj` extension. They store cut-outs, reusable shapes, export settings, the linked picture's local path, and a fingerprint of that picture so Photo Cutter can find it again and warn if a different image is selected later. The picture itself is not embedded. Before sharing a workspace file, remember that its stored path may include your Windows username or folder names. + +## Known limitations + +- GIF input is treated as a still image rather than an animation. +- HEIC, AVIF, TIFF, and SVG input are not currently supported. +- Very large pictures can briefly pause the interface while they are opened and validated. +- The portable executable is not code-signed, so Windows SmartScreen may show a first-launch warning. + ## Privacy Photo Cutter does not upload your images and does not require an account. Cutting, previews, workspaces, and exports are handled locally on your PC. @@ -66,6 +83,17 @@ dotnet run --project ".\solution\ImageUiSlicer\ImageUiSlicer.csproj" -c Release Developer references: [architecture](docs/ARCHITECTURE.md), [behaviour contract](docs/BEHAVIOR_CONTRACT.md), [contributing](.github/CONTRIBUTING.md), and [release process](docs/RELEASE.md). +## Project structure + +- `solution/ImageUiSlicer` — WPF application, canvas engine, models, services, workflows, and themes. +- `solution/ImageUiSlicer.Tests` — package-free regression-check executable used locally and in CI. +- `docs` — architecture, behaviour contract, changelog, release notes, and current screenshots. +- `.github` — CI, release automation, contribution guidance, and issue templates. + +## Contributing + +Read [CONTRIBUTING.md](.github/CONTRIBUTING.md) before proposing a change. Keep source-image geometry independent from display zoom, preserve the shared preview/export renderer, and add a regression check for behaviour changes. + ## License Photo Cutter is available under the [MIT License](LICENSE). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5f041cf..b980b3c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -94,6 +94,10 @@ Models hold workspace and editing data: source metadata, geometry, selections, cut-outs, saved shapes, export settings, and application settings. Persistent geometry remains independent of zoom and screen size. +`ProjectExportSettingsModel` keeps the preset, naming mode, and filename prefix +with each workspace. The export destination remains a local user preference and +is deliberately not written into portable workspace files. + Compatibility fields may remain even when they are no longer shown. Removing a field from the interface is not permission to remove it from saved workspace data. @@ -116,6 +120,7 @@ data. | `MainViewModel.ShapeTool.cs` | Combined built-in and saved-shape selector | | `MainViewModel.BrushRefinement.cs` | Edge-refinement stroke state and results | | `MainViewModel.Export.cs` | Export workflow invocation | +| `MainViewModel.OperationState.cs` | Long-running operation state, progress text, and cancellation | | `MainViewModel.ExportSettings.cs` | Destination, naming, scale, and outline choices | | `MainViewModel.ExportPresets.cs` | Preset application and reconciliation | | `MainViewModel.Preview.cs` | Inspector and gallery preview refresh | @@ -132,8 +137,12 @@ while only user-saved shapes expose deletion. ### `Workflows/Exporting` `ExportBatchCoordinator` owns ordered batch execution, selected-only filtering, -invalid-item skipping, safe numbering, and the final result summary. It calls -`ExportService` for each valid item. +invalid-item skipping, safe numbering, cancellation, progress reporting, and +the final result summary. It calls `ExportService` for each valid item. + +`IExportBatchCoordinator`, `ExportBatchResult`, and `ExportProgress` keep the +view model independent from the concrete execution class and separate workflow +input/output state from implementation. Export presets and editable settings remain in focused view-model partials; the coordinator is the extracted execution boundary, not a claim that the whole @@ -153,6 +162,10 @@ Services own reusable processing and persistence rather than WPF layout: - `SettingsService` stores local preferences. - `AtomicFile` provides replace-safe settings and workspace writes. +`Services/Contracts` contains the injected settings, project, and image service +boundaries. `Core/Images/ImageFormatCatalog` is the single source of truth for +the formats accepted by the file picker and decoder. + ### `Presentation/Preview` Preview-specific WPF work is separate from processing services: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 34975f4..58f6b2d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,36 @@ The format is based on Keep a Changelog and this project follows Semantic Versio ## [Unreleased] +## [1.3.0] - 2026-08-06 + +### Added +- Added remembered Precise, Balanced, and Direct freehand response choices plus Low, Medium, and High line smoothing, with **Shift** as a temporary precision override. +- Added cancellable background PNG export with visible per-cut-out progress while retaining files that completed before cancellation. +- Added source-image SHA-256 fingerprints to workspaces so a same-size but different replacement picture can be detected. +- Added a central supported-image-format catalogue and clear guidance for unsupported files. +- Stored each workspace's export preset, naming mode, and filename prefix with the workspace instead of borrowing them from the last-opened project. + +### Improved +- Made freehand point spacing consistent at every zoom level and kept the live pointer endpoint visible in both the canvas and precision magnifier. +- Added explicit service and workflow contracts for settings, projects, images, previews, and export coordination, allowing the editor to be tested without writing user settings. +- Split asynchronous command state, export results, progress, and operation state into focused files. +- Expanded regression coverage for high-resolution edges, image identity, workspace repair limits, cancellation, dependency boundaries, and thread-safe image/workspace transfer. +- Pinned CI and release workflow actions to reviewed commit revisions. + +### Fixed +- Rejected committed outlines that do not overlap the source image and preserved transparent export padding for cut-outs touching image edges. +- Cancelled interrupted Draw Around gestures cleanly instead of committing partial geometry. +- Restored project path and timestamp state when a workspace save fails. +- Added a geometry-point budget to undo history and aggregate limits to workspace loading to prevent avoidable memory exhaustion. +- Bounded loaded cut-out preview memory, stopped tiny previews being enlarged, and repaired unsafe workspace padding without aborting the open operation. +- Kept image and workspace opening on WPF's owning interface thread after a background-loading experiment caused cross-thread access errors. +- Prevented dropped files from replacing the source image during export, and made cancellation interrupt the in-progress PNG write while cleaning its temporary file. +- Enforced the same 500 cut-out safety limit in the editor, workspace save path, and workspace loader so an over-limit workspace cannot be created accidentally. +- Removed the duplicate-looking visible **100%** toolbar button; native one-pixel viewing remains available with **Ctrl+1**. + +### Documentation +- Refreshed the README, architecture notes, release guide, release notes, and application screenshot for the current interface. + ## [1.2.0] - 2026-07-17 ### Added diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 43db589..56bf916 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -8,8 +8,8 @@ This repository publishes Photo Cutter through GitHub Releases. 2. Create an annotated semantic version tag and push only that tag: ```powershell - git tag -a v1.2.0 -m "Photo Cutter v1.2.0" - git push origin v1.2.0 + git tag -a v1.3.0 -m "Photo Cutter v1.3.0" + git push origin v1.3.0 ``` 3. GitHub Actions workflow `Release Windows App` will: diff --git a/docs/releases/v1.3.0.md b/docs/releases/v1.3.0.md new file mode 100644 index 0000000..4a1f80e --- /dev/null +++ b/docs/releases/v1.3.0.md @@ -0,0 +1,26 @@ +## Added + +- Precise, Balanced, and Direct freehand response choices, three smoothing levels, and a temporary **Shift** precision override. +- Progress and cancellation when exporting selected or all cut-outs. +- Workspace image fingerprints that warn when a different source picture is selected. +- Workspace-specific export presets, naming modes, and filename prefixes. + +## Improved + +- Drawing remains responsive and accurately follows the pointer at every zoom level, including inside the precision magnifier. +- Image edges, transparent padding, workspace validation, failed saves, and large undo histories are handled more safely. +- Loaded-workspace thumbnails use bounded memory and no longer enlarge tiny cut-outs. +- Unsupported picture formats now receive a clear explanation; PNG, JPG/JPEG, BMP, GIF, and WEBP remain supported. + +## Fixed + +- Interrupted freehand drawing no longer commits an unfinished outline. +- Pictures and workspaces open without the WPF cross-thread access error introduced during responsiveness work. +- Dropping another file during export is safely ignored, and cancelling an export removes its unfinished temporary PNG. +- Workspaces now enforce the same 500 cut-out safety limit while editing, saving, and reopening, so the app cannot create a workspace it later refuses to open. +- The duplicate-looking **100%** toolbar button was removed; **Ctrl+1** still selects native one-pixel viewing. + +## Known Issues + +- GIF files open as still images. HEIC, AVIF, TIFF, and SVG are not supported. +- The portable executable is unsigned, so Windows SmartScreen may request confirmation on first launch. diff --git a/docs/screenshots/photo-cutter-main.png b/docs/screenshots/photo-cutter-main.png index b1fffa3..d8e9c55 100644 Binary files a/docs/screenshots/photo-cutter-main.png and b/docs/screenshots/photo-cutter-main.png differ diff --git a/solution/ImageUiSlicer.Tests/Program.DropChecks.cs b/solution/ImageUiSlicer.Tests/Program.DropChecks.cs index 5e046ad..82c2318 100644 --- a/solution/ImageUiSlicer.Tests/Program.DropChecks.cs +++ b/solution/ImageUiSlicer.Tests/Program.DropChecks.cs @@ -1,5 +1,8 @@ using System.IO; +using System.Reflection; +using System.Windows; using ImageUiSlicer.Views.Dialogs; +using SkiaSharp; namespace ImageUiSlicer.Tests; @@ -27,4 +30,82 @@ private static void MultipleDroppedImagesRequireConfirmedChoice() choice.ResolveSelection(accepted: true) == secondPath, "The chooser ignored the user's selected image."); } + + private static void BusyEditorRejectsDroppedFiles() + { + var currentImagePath = Path.Combine(Path.GetTempPath(), $"photo-cutter-current-{Guid.NewGuid():N}.png"); + var droppedImagePath = Path.Combine(Path.GetTempPath(), $"photo-cutter-dropped-{Guid.NewGuid():N}.png"); + try + { + WriteSolidPng(currentImagePath, 24, 24, SKColors.Coral); + WriteSolidPng(droppedImagePath, 32, 20, SKColors.CornflowerBlue); + + var viewModel = CreateViewModel(); + using var currentBitmap = new ImageUiSlicer.Services.ImageService().LoadBitmap(currentImagePath); + viewModel.ApplyImageProject(currentImagePath, currentBitmap.Copy()); + var window = new ImageUiSlicer.Views.MainWindow(viewModel) + { + ShowActivated = false, + ShowInTaskbar = false, + Opacity = 0, + }; + try + { + window.Show(); + window.UpdateLayout(); + Require(window.AllowDrop, "A ready editor did not accept file drops."); + Require( + viewModel.TryBeginOperation("Working…", CancellationToken.None, out _), + "The drop guard test could not enter the busy state."); + Require(!window.AllowDrop, "The main window still advertised file-drop support while busy."); + + var data = new DataObject(); + data.SetData(DataFormats.FileDrop, new[] { droppedImagePath }); + var constructor = typeof(DragEventArgs).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + [ + typeof(IDataObject), + typeof(DragDropKeyStates), + typeof(DragDropEffects), + typeof(DependencyObject), + typeof(Point), + ], + modifiers: null) ?? throw new InvalidOperationException("The WPF drop-event constructor was not found."); + var dropArgs = (DragEventArgs)constructor.Invoke( + [ + data, + DragDropKeyStates.None, + DragDropEffects.Copy, + window, + new Point(0, 0), + ]); + dropArgs.RoutedEvent = DragDrop.DropEvent; + + window.RaiseEvent(dropArgs); + + Require(dropArgs.Handled, "The rejected busy-state drop was allowed to continue routing."); + Require( + string.Equals(viewModel.Project.SourceImage.Path, currentImagePath, StringComparison.Ordinal), + "A dropped file replaced the active image during a long-running operation."); + + viewModel.CompleteOperation(); + Require(window.AllowDrop, "File-drop support did not return after the operation completed."); + } + finally + { + if (viewModel.IsBusy) + { + viewModel.CompleteOperation(); + } + + window.Close(); + } + } + finally + { + File.Delete(currentImagePath); + File.Delete(droppedImagePath); + } + } } diff --git a/solution/ImageUiSlicer.Tests/Program.ImageChecks.cs b/solution/ImageUiSlicer.Tests/Program.ImageChecks.cs index 95a79c7..4bff085 100644 --- a/solution/ImageUiSlicer.Tests/Program.ImageChecks.cs +++ b/solution/ImageUiSlicer.Tests/Program.ImageChecks.cs @@ -1,6 +1,8 @@ using System.IO; using ImageUiSlicer.CanvasEngine; +using ImageUiSlicer.Core.Images; using ImageUiSlicer.Services; +using ImageUiSlicer.Views; using SkiaSharp; namespace ImageUiSlicer.Tests; @@ -20,6 +22,30 @@ private static void FitViewNeverEnlargesSmallSourceImage() "Fit view did not reduce a source image that exceeds the viewport."); } + private static void ViewportControlsShareOneZoomStep() + { + using var source = CreateSolidBitmap(100, 100, SKColors.Coral); + using var renderTarget = new SKBitmap(300, 300, SKColorType.Bgra8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(renderTarget); + var viewModel = CreateViewModel(); + viewModel.ApplyImageProject("zoom-step.png", source.Copy()); + viewModel.SetCanvasView(1, 0, 0); + var controller = new CanvasController(); + controller.Attach(viewModel); + controller.Render(canvas, 300, 300); + + controller.ZoomIn(); + Require(Math.Abs(viewModel.CanvasScale - ViewportOptions.ZoomStep) < 0.0001, "The toolbar did not use the shared zoom step."); + + viewModel.SetCanvasView(1, 0, 0); + controller.OnMouseWheel(new System.Windows.Point(150, 150), 120); + Require(Math.Abs(viewModel.CanvasScale - ViewportOptions.ZoomStep) < 0.0001, "The mouse wheel did not use the shared zoom step."); + + viewModel.SetCanvasView(1, 0, 0); + controller.HandleKey(System.Windows.Input.Key.Add); + Require(Math.Abs(viewModel.CanvasScale - ViewportOptions.ZoomStep) < 0.0001, "The keyboard did not use the shared zoom step."); + } + private static void ImageLoadingPreservesOriginalPixelDimensions() { var imagePath = Path.Combine(Path.GetTempPath(), $"photo-cutter-source-{Guid.NewGuid():N}.png"); @@ -50,4 +76,100 @@ private static void ImageLoadingPreservesOriginalPixelDimensions() } } } + + private static void SourceFingerprintsDistinguishSameSizeImages() + { + var firstPath = Path.Combine(Path.GetTempPath(), $"photo-cutter-first-{Guid.NewGuid():N}.png"); + var secondPath = Path.Combine(Path.GetTempPath(), $"photo-cutter-second-{Guid.NewGuid():N}.png"); + try + { + WriteSolidPng(firstPath, 16, 16, SKColors.Coral); + WriteSolidPng(secondPath, 16, 16, SKColors.CornflowerBlue); + + var service = new ImageService(); + var first = service.ReadFingerprint(firstPath); + var second = service.ReadFingerprint(secondPath); + + Require(first.ContentSha256.Length == 64, "The source fingerprint was not a complete SHA-256 value."); + Require( + !string.Equals(first.ContentSha256, second.ContentSha256, StringComparison.OrdinalIgnoreCase), + "Different same-size images received the same source fingerprint."); + } + finally + { + File.Delete(firstPath); + File.Delete(secondPath); + } + } + + private static void SupportedPictureFormatsComeFromOneCatalog() + { + foreach (var extension in new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" }) + { + Require(ImageFormatCatalog.SupportsFile("picture" + extension), $"{extension} disappeared from the supported picture formats."); + Require(ImageFormatCatalog.DialogPattern.Contains("*" + extension, StringComparison.OrdinalIgnoreCase), $"{extension} is accepted by the decoder but missing from the file picker."); + } + + Require(!ImageFormatCatalog.SupportsFile("picture.heic"), "HEIC was advertised even though the decoder does not guarantee it."); + } + + private static void BackgroundLoadedImageTransfersToEditor() + { + var imagePath = Path.Combine(Path.GetTempPath(), $"photo-cutter-thread-{Guid.NewGuid():N}.png"); + SKBitmap? loadedBitmap = null; + try + { + WriteSolidPng(imagePath, 32, 24, SKColors.CornflowerBlue); + var service = new ImageService(); + var loaded = Task.Run(() => + { + var fingerprint = service.ReadFingerprint(imagePath); + var bitmap = service.LoadBitmap(imagePath); + return (fingerprint, bitmap); + }) + .GetAwaiter() + .GetResult(); + loadedBitmap = loaded.bitmap; + + var viewModel = CreateViewModel(); + var window = new MainWindow(viewModel) + { + ShowActivated = false, + ShowInTaskbar = false, + Opacity = 0, + }; + try + { + window.Show(); + window.UpdateLayout(); + viewModel.ApplyImageProject(imagePath, loadedBitmap, loaded.fingerprint); + loadedBitmap = null; + window.UpdateLayout(); + + Require(viewModel.HasImage, "The background-loaded bitmap was not accepted by the editor."); + Require(viewModel.SourceBitmap?.Width == 32 && viewModel.SourceBitmap.Height == 24, "The transferred bitmap dimensions changed."); + } + finally + { + window.Close(); + } + } + finally + { + loadedBitmap?.Dispose(); + if (File.Exists(imagePath)) + { + File.Delete(imagePath); + } + } + } + + private static void WriteSolidPng(string path, int width, int height, SKColor color) + { + using var bitmap = CreateSolidBitmap(width, height, color); + using var image = SKImage.FromBitmap(bitmap); + using var encoded = image.Encode(SKEncodedImageFormat.Png, 100); + using var output = File.Create(path); + encoded.SaveTo(output); + } } diff --git a/solution/ImageUiSlicer.Tests/Program.ProjectChecks.cs b/solution/ImageUiSlicer.Tests/Program.ProjectChecks.cs index acc15fc..9dcdf02 100644 --- a/solution/ImageUiSlicer.Tests/Program.ProjectChecks.cs +++ b/solution/ImageUiSlicer.Tests/Program.ProjectChecks.cs @@ -5,6 +5,7 @@ using ImageUiSlicer.Models; using ImageUiSlicer.Services; using ImageUiSlicer.ViewModels; +using ImageUiSlicer.Views; using SkiaSharp; namespace ImageUiSlicer.Tests; @@ -14,7 +15,7 @@ internal static partial class Program private static void VisibleActionWordingStaysConsistent() { using var source = CreateSolidBitmap(60, 60, SKColors.Coral); - var viewModel = new MainViewModel(); + var viewModel = CreateViewModel(); viewModel.ApplyImageProject("wording-check.png", source); viewModel.SetActiveTool(CanvasTool.BrushAdd); @@ -81,6 +82,14 @@ private static void WorkspaceRoundTripPreservesCompatibleData() Path = "source.png", PixelWidth = 100, PixelHeight = 100, + FileSizeBytes = 12345, + ContentSha256 = new string('a', 64), + }, + ExportSettings = new ProjectExportSettingsModel + { + Preset = "uiAssets", + NamingMode = "cutoutNames", + Prefix = "ui_", }, Cutouts = new ObservableCollection { cutout }, SavedShapes = new ObservableCollection { savedShape }, @@ -91,6 +100,11 @@ private static void WorkspaceRoundTripPreservesCompatibleData() var loaded = service.Load(projectPath); Require(loaded.CreatedUtc == createdUtc, "The workspace creation date was lost."); + Require(loaded.SourceImage.FileSizeBytes == 12345, "The source image file size was lost."); + Require(loaded.SourceImage.ContentSha256 == new string('a', 64), "The source image fingerprint was lost."); + Require(loaded.ExportSettings.Preset == "uiAssets", "The workspace export preset was lost."); + Require(loaded.ExportSettings.NamingMode == "cutoutNames", "The workspace naming mode was lost."); + Require(loaded.ExportSettings.Prefix == "ui_", "The workspace export prefix was lost."); Require(loaded.Cutouts.Single().Notes == cutout.Notes, "Legacy cut-out notes were lost."); Require(loaded.Cutouts.Single().AutoConfidence == cutout.AutoConfidence, "Legacy confidence data was lost."); Require(loaded.Cutouts.Single().Export.Format == "png", "The legacy output format was not safely normalised to PNG."); @@ -103,10 +117,185 @@ private static void WorkspaceRoundTripPreservesCompatibleData() } } + private static void WorkspaceExportSettingsStayWithWorkspace() + { + var projectPath = Path.Combine(Path.GetTempPath(), $"photo-cutter-export-settings-{Guid.NewGuid():N}.iusproj"); + try + { + using var source = CreateSolidBitmap(40, 30, SKColors.CornflowerBlue); + var viewModel = CreateViewModel(); + viewModel.ApplyImageProject("workspace-export-settings.png", source.Copy()); + viewModel.ExportPreset = "sprites"; + viewModel.SaveProject(projectPath); + + var loaded = new ProjectService().Load(projectPath); + var reopenedViewModel = CreateViewModel(); + reopenedViewModel.ExportPreset = "icons"; + reopenedViewModel.ApplyLoadedProject(loaded, source.Copy(), projectPath); + + Require(reopenedViewModel.ExportPreset == "sprites", "A different session's preset replaced the workspace preset."); + Require(reopenedViewModel.ExportNamingMode == "autoNumbered", "The workspace naming mode was not restored."); + Require(reopenedViewModel.ExportPrefix == "spr_", "The workspace export prefix was not restored."); + } + finally + { + if (File.Exists(projectPath)) + { + File.Delete(projectPath); + } + } + } + + private static void UnsafeWorkspacePaddingIsRepaired() + { + var projectPath = Path.Combine(Path.GetTempPath(), $"photo-cutter-padding-{Guid.NewGuid():N}.iusproj"); + try + { + var cutout = CreateCutout( + "Unsafe padding", + new PointF(2, 2), + new PointF(18, 2), + new PointF(18, 18), + new PointF(2, 18)); + cutout.Export.Padding = int.MaxValue; + var project = new ProjectModel + { + ProjectName = "Padding repair", + SourceImage = new SourceImageModel { Path = "padding.png", PixelWidth = 20, PixelHeight = 20 }, + Defaults = new ExportOptionsModel { Padding = int.MaxValue, TargetPixelSize = -1 }, + Cutouts = new ObservableCollection { cutout }, + }; + + var service = new ProjectService(); + service.Save(project, projectPath); + var result = service.LoadWithReport(projectPath); + + Require(result.WasRepaired, "Unsafe workspace padding was changed without reporting a repair."); + Require(result.Project.Defaults.Padding == ExportOptionsPolicy.MaxPadding, "Default padding was not safely bounded."); + Require(result.Project.Cutouts.Single().Export.Padding == ExportOptionsPolicy.MaxPadding, "Cut-out padding was not safely bounded."); + + using var source = CreateSolidBitmap(20, 20, SKColors.Coral); + var previewFactory = new ImageUiSlicer.Presentation.Preview.CutoutPreviewFactory(); + _ = previewFactory.BuildPreviewImage(source, result.Project.Cutouts.Single()); + } + finally + { + if (File.Exists(projectPath)) + { + File.Delete(projectPath); + } + } + } + + private static void FailedWorkspaceSavePreservesProjectState() + { + var previousModifiedUtc = new DateTime(2025, 7, 6, 5, 4, 3, DateTimeKind.Utc); + var project = new ProjectModel + { + ProjectName = "Unsaved state", + ProjectFilePath = "existing-workspace.iusproj", + ModifiedUtc = previousModifiedUtc, + }; + var failed = false; + try + { + new ProjectService().Save(project, Path.Combine(Path.GetTempPath(), "invalid\0workspace.iusproj")); + } + catch + { + failed = true; + } + + Require(failed, "The invalid save path unexpectedly succeeded."); + Require(project.ProjectFilePath == "existing-workspace.iusproj", "A failed save replaced the live workspace path."); + Require(project.ModifiedUtc == previousModifiedUtc, "A failed save changed the live workspace timestamp."); + } + + private static void BackgroundLoadedWorkspaceTransfersToEditor() + { + var projectPath = Path.Combine(Path.GetTempPath(), $"photo-cutter-thread-{Guid.NewGuid():N}.iusproj"); + try + { + var cutout = CreateCutout( + "Thread transfer", + new PointF(2, 2), + new PointF(20, 2), + new PointF(20, 20), + new PointF(2, 20)); + var project = new ProjectModel + { + ProjectName = "Thread transfer", + SourceImage = new SourceImageModel { Path = "thread-transfer.png", PixelWidth = 32, PixelHeight = 24 }, + Cutouts = new ObservableCollection { cutout }, + SavedShapes = new ObservableCollection { CreateSavedShape("Saved", 0) }, + }; + var service = new ProjectService(); + service.Save(project, projectPath); + var loaded = Task.Run(() => service.LoadWithReport(projectPath)).GetAwaiter().GetResult(); + using var source = CreateSolidBitmap(32, 24, SKColors.CornflowerBlue); + var viewModel = CreateViewModel(); + var window = new MainWindow(viewModel) + { + ShowActivated = false, + ShowInTaskbar = false, + Opacity = 0, + }; + try + { + window.Show(); + window.UpdateLayout(); + viewModel.ApplyLoadedProject(loaded.Project, source.Copy(), projectPath); + window.UpdateLayout(); + Require(viewModel.Project.Cutouts.Count == 1, "The background-loaded cut-out collection was not accepted."); + Require(viewModel.Project.SavedShapes.Count == 1, "The background-loaded saved-shape collection was not accepted."); + } + finally + { + window.Close(); + } + } + finally + { + if (File.Exists(projectPath)) + { + File.Delete(projectPath); + } + } + } + + private static void OffImageActiveSelectionCannotBeCommitted() + { + using var source = CreateSolidBitmap(50, 50, SKColors.Coral); + var viewModel = CreateViewModel(); + viewModel.ApplyImageProject("commit-boundary.png", source.Copy()); + viewModel.SetActiveSelection(new PathGeometryModel + { + Type = "path", + Mode = "freehand", + Closed = true, + Points = + [ + new PointF(100, 100), + new PointF(120, 100), + new PointF(120, 120), + new PointF(100, 120), + ], + }); + + Require(viewModel.CommitSelectionCommand.CanExecute(null), "The commit boundary test did not create an active outline."); + viewModel.CommitSelectionCommand.Execute(null); + + Require(viewModel.Project.Cutouts.Count == 0, "An outline outside the source image was committed."); + Require(viewModel.HasActiveSelection, "A rejected outline was discarded instead of remaining available to move."); + Require( + viewModel.StatusText.Contains("outside", StringComparison.OrdinalIgnoreCase), + "The rejected outline did not explain why it could not be saved."); + } + private static void SavedShapePlacementKeepsExactSize() { using var bitmap = CreateSolidBitmap(200, 200, SkiaSharp.SKColors.Coral); - var viewModel = new MainViewModel(); + var viewModel = CreateViewModel(); viewModel.ApplyImageProject("placement.png", bitmap.Copy()); var savedShape = CreateSavedShape("Exact shape", 0); @@ -126,7 +315,7 @@ private static void SavedShapePlacementKeepsExactSize() private static void CutoutOrderingDoesNotMoveGeometry() { - var viewModel = new MainViewModel(); + var viewModel = CreateViewModel(); var first = CreateCutout( "First", new PointF(1, 1), @@ -155,4 +344,47 @@ private static void CutoutOrderingDoesNotMoveGeometry() viewModel.UndoCommand.Execute(null); Require(viewModel.Project.Cutouts[0].Name == "First", "Undo did not restore the original export order."); } + + private static void UndoHistoryAppliesGeometryMemoryBudget() + { + const int pointCount = 200_000; + var points = Enumerable.Range(0, pointCount) + .Select(index => + { + var angle = index * (MathF.PI * 2f / pointCount); + return new PointF(100f + (MathF.Cos(angle) * 50f), 100f + (MathF.Sin(angle) * 50f)); + }) + .ToList(); + var geometry = new PathGeometryModel + { + Type = "path", + Mode = "freehand", + Closed = true, + Points = points, + }; + var cutout = new CutoutModel + { + Name = "Dense outline", + Geometry = geometry, + BBox = GeometryHelper.ComputeBBox(points), + }; + var viewModel = CreateViewModel(); + viewModel.Project.Cutouts.Add(cutout); + viewModel.SelectSingleCutout(cutout); + + foreach (var name in new[] { "Dense one", "Dense two", "Dense three" }) + { + viewModel.SelectedCutoutNameDraft = name; + viewModel.RenameSelectedCutoutCommand.Execute(null); + } + + var undoStack = typeof(MainViewModel) + .GetField("_undoStack", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(viewModel); + var retainedCount = (int?)undoStack?.GetType().GetProperty("Count")?.GetValue(undoStack); + Require(retainedCount is > 0 and <= 2, "Dense geometry retained an unbounded number of full history snapshots."); + + viewModel.UndoCommand.Execute(null); + Require(viewModel.Project.Cutouts.Single().Name == "Dense two", "The memory budget broke the most recent undo step."); + } } diff --git a/solution/ImageUiSlicer.Tests/Program.RenderingChecks.cs b/solution/ImageUiSlicer.Tests/Program.RenderingChecks.cs index 84782e9..9d67377 100644 --- a/solution/ImageUiSlicer.Tests/Program.RenderingChecks.cs +++ b/solution/ImageUiSlicer.Tests/Program.RenderingChecks.cs @@ -3,12 +3,29 @@ using ImageUiSlicer.Core.Geometry; using ImageUiSlicer.Models; using ImageUiSlicer.Services; +using ImageUiSlicer.Presentation.Preview; using SkiaSharp; namespace ImageUiSlicer.Tests; internal static partial class Program { + private static void SmallCutoutPreviewsAreNotEnlarged() + { + using var source = CreateSolidBitmap(20, 20, SKColors.Coral); + var cutout = CreateCutout( + "Small", + new PointF(1, 1), + new PointF(2, 1), + new PointF(2, 2), + new PointF(1, 2)); + cutout.Export.TargetPixelSize = -1; + + var preview = new CutoutPreviewFactory().BuildPreviewImage(source, cutout); + Require(preview is not null, "The small cut-out preview was not created."); + Require(preview!.PixelWidth <= 2 && preview.PixelHeight <= 2, "A tiny cut-out was enlarged into a memory-heavy thumbnail."); + } + private static void InspectorPreviewIncludesExportPadding() { using var source = CreateSolidBitmap(20, 20, SKColors.Coral); @@ -34,6 +51,31 @@ private static void InspectorPreviewIncludesExportPadding() "The inspector crop did not include the same padding as the exported cut-out."); } + private static void EdgeCutoutPreservesTransparentPadding() + { + using var source = CreateSolidBitmap(20, 20, SKColors.Coral); + var cutout = CreateCutout( + "Edge padded", + new PointF(0, 0), + new PointF(10, 0), + new PointF(10, 10), + new PointF(0, 10)); + cutout.Export.Padding = 3; + cutout.Export.TargetPixelSize = -1; + + using var exported = new CutoutRenderService().RenderCutoutBitmap(source, cutout); + using var preview = new CutoutRenderService().RenderInspectorPreviewBitmap( + source, + cutout, + splitPreview: false, + splitRatio: 0.5); + + Require(exported.Width == 16 && exported.Height == 16, "Padding touching the image edge was removed from the export canvas."); + Require(exported.GetPixel(0, 0).Alpha == 0, "The new edge padding was not transparent."); + Require(exported.GetPixel(6, 6).Alpha > 0, "The source pixels moved out of the padded export canvas."); + Require(preview is not null && preview.Width == 16 && preview.Height == 16, "The inspector preview disagrees with edge export padding."); + } + private static void EveryBuiltInShapeCreatesValidGeometry() { foreach (var definition in ShapePresetCatalog.BuiltIns) @@ -147,4 +189,146 @@ private static void BatchExportPreservesOrderAndSkipsInvalidItems() } } } + + private static void BackgroundExportSupportsProgressAndCancellation() + { + var folder = Path.Combine(Path.GetTempPath(), $"photo-cutter-async-{Guid.NewGuid():N}"); + try + { + using var source = CreateSolidBitmap(40, 40, SKColors.Coral); + var cutout = CreateCutout( + "Async", + new PointF(1, 1), + new PointF(20, 1), + new PointF(20, 20), + new PointF(1, 20)); + var progressValues = new List(); + var progress = new InlineProgress(progressValues.Add); + var coordinator = new ExportBatchCoordinator(); + + var result = coordinator.ExportAsync( + source, + new[] { cutout }, + selectedCutouts: null, + folder, + "autoNumbered", + string.Empty, + progress, + CancellationToken.None) + .GetAwaiter() + .GetResult(); + + Require(result.ExportedCount == 1, "The background export did not finish its cut-out."); + Require(progressValues.Count == 1 && progressValues[0].Completed == 1, "Background export progress was not reported."); + + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var wasCancelled = false; + try + { + coordinator.ExportAsync( + source, + new[] { cutout }, + selectedCutouts: null, + folder, + "autoNumbered", + "cancelled_", + progress: null, + cancellation.Token) + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException) + { + wasCancelled = true; + } + + Require(wasCancelled, "A cancelled background export continued running."); + Require(!File.Exists(Path.Combine(folder, "cancelled_001.png")), "A pre-cancelled export wrote a PNG."); + + using var largeSource = CreateNoiseBitmap(2048, 2048); + var largeCutout = CreateCutout( + "Writing", + new PointF(0, 0), + new PointF(largeSource.Width, 0), + new PointF(largeSource.Width, largeSource.Height), + new PointF(0, largeSource.Height)); + using var writeCancellation = new CancellationTokenSource(); + using var temporaryFileSeen = new ManualResetEventSlim(); + using var watcher = new FileSystemWatcher(folder, "*.tmp") + { + NotifyFilter = NotifyFilters.FileName, + EnableRaisingEvents = true, + }; + watcher.Created += (_, args) => + { + if (string.IsNullOrEmpty(args.Name) || + !args.Name.StartsWith(".photo-cutter-", StringComparison.Ordinal)) + { + return; + } + + temporaryFileSeen.Set(); + writeCancellation.Cancel(); + }; + + var writeWasCancelled = false; + try + { + coordinator.ExportAsync( + largeSource, + new[] { largeCutout }, + selectedCutouts: null, + folder, + "autoNumbered", + "writing_", + progress: null, + writeCancellation.Token) + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException) + { + writeWasCancelled = true; + } + + Require(temporaryFileSeen.IsSet, "The mid-export check never reached the PNG-writing stage."); + Require(writeWasCancelled, "Cancellation during PNG writing did not stop the export."); + Require(!File.Exists(Path.Combine(folder, "writing_001.png")), "A cancelled in-progress PNG was published."); + Require( + !Directory.EnumerateFiles(folder, ".photo-cutter-*.tmp").Any(), + "Cancellation left a temporary PNG file behind."); + } + finally + { + if (Directory.Exists(folder)) + { + Directory.Delete(folder, recursive: true); + } + } + } + + private sealed class InlineProgress(Action report) : IProgress + { + public void Report(T value) => report(value); + } + + private static SKBitmap CreateNoiseBitmap(int width, int height) + { + var bitmap = new SKBitmap(width, height, SKColorType.Bgra8888, SKAlphaType.Premul); + var pixels = new SKColor[checked(width * height)]; + uint state = 0x6D2B79F5; + for (var index = 0; index < pixels.Length; index++) + { + state = (state * 1664525) + 1013904223; + pixels[index] = new SKColor( + (byte)(state >> 24), + (byte)(state >> 16), + (byte)(state >> 8), + 255); + } + + bitmap.Pixels = pixels; + return bitmap; + } } diff --git a/solution/ImageUiSlicer.Tests/Program.UiContractChecks.cs b/solution/ImageUiSlicer.Tests/Program.UiContractChecks.cs index e784eeb..a5fee10 100644 --- a/solution/ImageUiSlicer.Tests/Program.UiContractChecks.cs +++ b/solution/ImageUiSlicer.Tests/Program.UiContractChecks.cs @@ -2,20 +2,101 @@ using System.Windows.Controls; using System.Windows.Media; using ImageUiSlicer.CanvasEngine; +using ImageUiSlicer.CanvasEngine.Freehand; using ImageUiSlicer.CanvasEngine.Contracts; +using ImageUiSlicer.Models; using ImageUiSlicer.ViewModels; using ImageUiSlicer.Views; using ImageUiSlicer.Views.Components; +using SkiaSharp; namespace ImageUiSlicer.Tests; internal static partial class Program { + private static void FreehandResponseModesPreserveCursorAlignment() + { + foreach (var response in Enum.GetValues()) + { + var points = new List { new(0, 0), new(2, 1) }; + var pointer = new PointF(5, 3); + Require( + FreehandPathProcessor.TryAddPoint(points, pointer, response, FreehandSmoothing.High), + $"{response} response rejected a valid pointer movement."); + Require(points[^1] == pointer, $"{response} response allowed the line to lag behind the pointer."); + } + + var balanced = new List { new(0, 0) }; + var precise = new List { new(0, 0) }; + Require(!FreehandPathProcessor.TryAddPoint( + balanced, new PointF(0.5f, 0), FreehandResponse.Balanced, FreehandSmoothing.Low), + "Balanced response stopped filtering tiny movements."); + Require(FreehandPathProcessor.TryAddPoint( + precise, new PointF(0.5f, 0), FreehandResponse.Balanced, FreehandSmoothing.Low, precisionOverride: true), + "Holding Shift did not enable the temporary precise response."); + Require(!FreehandPathProcessor.TryAddPoint( + precise, precise[^1], FreehandResponse.Direct, FreehandSmoothing.Low, force: true), + "Releasing over the last point added a duplicate freehand point."); + + foreach (var viewScale in new[] { 0.25f, 1f, 5f, 10f, 32f }) + { + var accepted = new List { new(0, 0) }; + var rejected = new List { new(0, 0) }; + Require( + FreehandPathProcessor.TryAddPoint( + accepted, + new PointF(2f / viewScale, 0), + FreehandResponse.Balanced, + FreehandSmoothing.Low, + viewScale), + $"Balanced response rejected the same 2 px movement at {viewScale:0.##}x zoom."); + Require( + !FreehandPathProcessor.TryAddPoint( + rejected, + new PointF(1f / viewScale, 0), + FreehandResponse.Balanced, + FreehandSmoothing.Low, + viewScale), + $"Balanced response stopped filtering the same 1 px movement at {viewScale:0.##}x zoom."); + } + + var viewModel = CreateViewModel(); + Require(viewModel.FreehandResponseOptions.SequenceEqual(Enum.GetValues()), + "A freehand response choice is missing from View Settings."); + Require(viewModel.FreehandSmoothingOptions.SequenceEqual(Enum.GetValues()), + "A freehand smoothing choice is missing from View Settings."); + } + + private static void InterruptedFreehandGestureIsCancelled() + { + using var source = CreateSolidBitmap(100, 100, SKColors.Coral); + using var renderTarget = new SKBitmap(200, 200, SKColorType.Bgra8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(renderTarget); + var viewModel = CreateViewModel(); + viewModel.ApplyImageProject("interrupted-drawing.png", source.Copy()); + viewModel.SetCanvasView(1, 0, 0); + + var controller = new CanvasController(); + controller.Attach(viewModel); + controller.Render(canvas, 200, 200); + controller.OnMouseDown(new Point(60, 60), System.Windows.Input.ModifierKeys.None, 1); + controller.OnMouseMove(new Point(80, 60), leftButtonPressed: true, panButtonPressed: false, System.Windows.Input.ModifierKeys.None); + controller.OnMouseMove(new Point(80, 80), leftButtonPressed: true, panButtonPressed: false, System.Windows.Input.ModifierKeys.None); + + controller.OnMouseLeave(); + controller.OnMouseUp(new Point(60, 80)); + + Require(!viewModel.HasActiveSelection, "A lost pointer capture committed a partial Draw Around outline."); + Require( + viewModel.StatusText.Contains("cancelled", StringComparison.OrdinalIgnoreCase), + "Interrupted drawing did not explain that the temporary line was cancelled."); + } + private static void CompactHeaderProtectsPhotoPreviewSpace() { const double maxTwoRowHeaderHeight = 104; const double wideHeaderContractWidth = 1436; - var window = new MainWindow + var window = new MainWindow(CreateViewModel()) { Width = 1460, Height = 780, @@ -64,7 +145,7 @@ private static void CompactHeaderProtectsPhotoPreviewSpace() private static void CutToolsUsePlainEnglishAndFiveLensLevels() { - var window = new MainWindow(); + var window = new MainWindow(CreateViewModel()); try { window.ShowActivated = false; @@ -88,6 +169,8 @@ private static void CutToolsUsePlainEnglishAndFiveLensLevels() .ToHashSet(StringComparer.Ordinal); Require(buttonLabels.Contains("Save as Reusable Shape"), "The saved-shape action no longer describes both valid sources."); Require(!buttonLabels.Contains("Save Current Outline as Shape"), "The misleading outline-only saved-shape label returned."); + Require(buttonLabels.Contains("Fit"), "The simple Fit view control was removed."); + Require(!buttonLabels.Contains("100%"), "The duplicate-looking 100% view button returned to the toolbar."); var viewModel = (MainViewModel)window.DataContext; Require( @@ -102,7 +185,7 @@ private static void CutToolsUsePlainEnglishAndFiveLensLevels() private static void OrderingControlsStayCompactAndCommandBound() { - var window = new MainWindow(); + var window = new MainWindow(CreateViewModel()); try { window.ShowActivated = false; @@ -159,7 +242,7 @@ private static void LightAppearanceAccentTextStaysReadable() private static void PaneBoundsProtectImagePreview() { - var window = new MainWindow(); + var window = new MainWindow(CreateViewModel()); try { var left = window.FindName("LeftPaneColumn") as ColumnDefinition ?? @@ -222,7 +305,7 @@ private static void PaneBoundsProtectImagePreview() private static void SidePanelControlsStayCompact() { - var window = new MainWindow + var window = new MainWindow(CreateViewModel()) { Width = 1000, Height = 620, diff --git a/solution/ImageUiSlicer.Tests/Program.cs b/solution/ImageUiSlicer.Tests/Program.cs index 749b536..16e2f27 100644 --- a/solution/ImageUiSlicer.Tests/Program.cs +++ b/solution/ImageUiSlicer.Tests/Program.cs @@ -1,4 +1,5 @@ using ImageUiSlicer.Core.Geometry; +using ImageUiSlicer.CanvasEngine.Freehand; using ImageUiSlicer.Models; using ImageUiSlicer.Services; using ImageUiSlicer.ViewModels; @@ -20,17 +21,31 @@ private static int Main() [ ("Full-image geometry exports every pixel", FullImageGeometryExportsEveryPixel), ("Image loading preserves original pixel dimensions", ImageLoadingPreservesOriginalPixelDimensions), + ("Supported picture formats come from one catalogue", SupportedPictureFormatsComeFromOneCatalog), + ("Background-loaded images transfer safely to the editor", BackgroundLoadedImageTransfersToEditor), + ("Source fingerprints distinguish same-size images", SourceFingerprintsDistinguishSameSizeImages), ("Fit view never enlarges a small source image", FitViewNeverEnlargesSmallSourceImage), + ("All viewport controls share one zoom step", ViewportControlsShareOneZoomStep), ("Off-image geometry is rejected", OffImageGeometryIsRejected), ("Split preview preserves before/after transparency", SplitPreviewIsCorrect), ("Inspector preview includes export padding", InspectorPreviewIncludesExportPadding), + ("Edge cut-outs preserve transparent export padding", EdgeCutoutPreservesTransparentPadding), + ("Small cut-out previews are not enlarged", SmallCutoutPreviewsAreNotEnlarged), ("Large curves use adaptive geometry", LargeCurvesUseAdaptiveGeometry), ("Every built-in shape creates valid geometry", EveryBuiltInShapeCreatesValidGeometry), ("Interior brush clicks do not resample an edge", InteriorBrushClickIsIgnored), ("Malformed workspace geometry is contained", MalformedWorkspaceGeometryIsContained), + ("Workspace cut-out limit is enforced during save and load", WorkspaceCutoutLimitIsEnforcedDuringSaveAndLoad), + ("Editor blocks cut-outs beyond the workspace limit", EditorBlocksCutoutsBeyondWorkspaceLimit), ("Workspace round-trip preserves compatible project data", WorkspaceRoundTripPreservesCompatibleData), + ("Workspace export settings stay with their workspace", WorkspaceExportSettingsStayWithWorkspace), + ("Unsafe workspace padding is repaired", UnsafeWorkspacePaddingIsRepaired), + ("Background-loaded workspaces transfer safely to the editor", BackgroundLoadedWorkspaceTransfersToEditor), + ("Failed workspace saves preserve live project state", FailedWorkspaceSavePreservesProjectState), ("New active outline clears committed selection", ActiveOutlineClearsCommittedSelection), + ("Off-image active outlines cannot be committed", OffImageActiveSelectionCannotBeCommitted), ("Switching away from an active outline is undoable", ActiveOutlineSwitchIsUndoable), + ("Undo history applies a geometry memory budget", UndoHistoryAppliesGeometryMemoryBudget), ("Saved-shape management and placement stay synchronized", SavedShapeSelectorsStaySynchronized), ("Saved-shape placement keeps the exact saved size", SavedShapePlacementKeepsExactSize), ("Saved shapes can be renamed and undone", SavedShapeRenameIsUndoable), @@ -39,10 +54,14 @@ private static int Main() ("Cut-out ordering never moves image geometry", CutoutOrderingDoesNotMoveGeometry), ("Movement clamping preserves image boundaries", MovementClampingPreservesImageBoundaries), ("Multiple dropped images require a confirmed choice", MultipleDroppedImagesRequireConfirmedChoice), + ("Busy editor rejects dropped files", BusyEditorRejectsDroppedFiles), ("PNG export uses safe unique filenames", PngExportUsesSafeUniqueFilenames), ("Batch export preserves project order and skips invalid items", BatchExportPreservesOrderAndSkipsInvalidItems), + ("Background export supports progress and cancellation", BackgroundExportSupportsProgressAndCancellation), ("Ordering controls stay compact and command-bound", OrderingControlsStayCompactAndCommandBound), ("Cut tools use plain English and five lens levels", CutToolsUsePlainEnglishAndFiveLensLevels), + ("Freehand response modes preserve cursor alignment", FreehandResponseModesPreserveCursorAlignment), + ("Interrupted freehand drawing is cancelled cleanly", InterruptedFreehandGestureIsCancelled), ("Compact header protects photo preview space", CompactHeaderProtectsPhotoPreviewSpace), ("Light and dark themes expose the same resources", ThemePalettesExposeMatchingResources), ("Light appearance accent text stays readable", LightAppearanceAccentTextStaysReadable), @@ -50,6 +69,8 @@ private static int Main() ("Side-panel controls stay compact", SidePanelControlsStayCompact), ("Visible action wording stays consistent", VisibleActionWordingStaysConsistent), ("Canvas engine depends on an editor contract, not the main view model", CanvasUsesEditorContract), + ("View models use injected in-memory settings", ViewModelsUseInjectedInMemorySettings), + ("Long operations lock and restore editor commands", LongOperationsLockAndRestoreEditorCommands), ("Custom scrollbars preserve orientation and range", ScrollBarTemplatePreservesControlState), ("Gallery loads in both appearances", GalleryLoadsInBothAppearances), ]; @@ -211,9 +232,12 @@ private static void MalformedWorkspaceGeometryIsContained() try { File.WriteAllText(projectPath, projectJson); - var project = new ProjectService().Load(projectPath); + var loadResult = new ProjectService().LoadWithReport(projectPath); + var project = loadResult.Project; Require(project.Cutouts.Count == 2, "Null workspace entries were not safely removed."); + Require(loadResult.WasRepaired, "Workspace repairs were not reported to the caller."); + Require(loadResult.Warnings.Count >= 3, "The workspace repair report omitted destructive repairs."); Require( project.Cutouts.Select(cutout => cutout.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 2, "Duplicate workspace IDs were not repaired."); @@ -227,9 +251,97 @@ private static void MalformedWorkspaceGeometryIsContained() } } + private static void WorkspaceCutoutLimitIsEnforcedDuringSaveAndLoad() + { + var projectPath = Path.Combine(Path.GetTempPath(), $"photo-cutter-limit-{Guid.NewGuid():N}.iusproj"); + var rejectedPath = Path.Combine(Path.GetTempPath(), $"photo-cutter-over-limit-{Guid.NewGuid():N}.iusproj"); + try + { + var project = new ProjectModel + { + ProjectName = "Cut-out limit", + Cutouts = new System.Collections.ObjectModel.ObservableCollection( + Enumerable.Range(0, WorkspacePolicy.MaxCutouts).Select(index => CreateCutout( + $"Cut-out {index + 1}", + new PointF(0, 0), + new PointF(10, 0), + new PointF(10, 10), + new PointF(0, 10)))), + }; + var service = new ProjectService(); + service.Save(project, projectPath); + var loaded = service.LoadWithReport(projectPath).Project; + Require( + loaded.Cutouts.Count == WorkspacePolicy.MaxCutouts, + "A workspace at the documented cut-out limit did not round-trip."); + + var previousPath = project.ProjectFilePath; + var previousModifiedUtc = project.ModifiedUtc; + project.Cutouts.Add(CreateCutout( + "One too many", + new PointF(0, 0), + new PointF(10, 0), + new PointF(10, 10), + new PointF(0, 10))); + + var rejected = false; + try + { + service.Save(project, rejectedPath); + } + catch (InvalidDataException) + { + rejected = true; + } + + Require(rejected, "A workspace above the cut-out limit was saved."); + Require(!File.Exists(rejectedPath), "A rejected workspace save still wrote a file."); + Require(project.ProjectFilePath == previousPath, "A rejected workspace save changed the live project path."); + Require(project.ModifiedUtc == previousModifiedUtc, "A rejected workspace save changed the live project timestamp."); + } + finally + { + File.Delete(projectPath); + File.Delete(rejectedPath); + } + } + + private static void EditorBlocksCutoutsBeyondWorkspaceLimit() + { + using var source = CreateSolidBitmap(64, 64, SKColors.Coral); + var viewModel = CreateViewModel(); + viewModel.ApplyImageProject("cutout-limit.png", source.Copy()); + viewModel.Project.Cutouts = new System.Collections.ObjectModel.ObservableCollection( + Enumerable.Range(0, WorkspacePolicy.MaxCutouts - 1).Select(index => CreateCutout( + $"Cut-out {index + 1}", + new PointF(0, 0), + new PointF(10, 0), + new PointF(10, 10), + new PointF(0, 10)))); + + viewModel.SetActiveSelection(CreateSquareGeometry(20)); + Require(viewModel.CommitSelectionCommand.CanExecute(null), "The final allowed cut-out could not be saved."); + viewModel.CommitSelectionCommand.Execute(null); + Require( + viewModel.Project.Cutouts.Count == WorkspacePolicy.MaxCutouts, + "The final allowed cut-out was not committed."); + + viewModel.SetActiveSelection(CreateSquareGeometry(30)); + Require(!viewModel.CanCommitOrFinalizeSelection, "The editor still advertised another cut-out at the limit."); + Require(!viewModel.CommitSelectionCommand.CanExecute(null), "The save command stayed enabled at the cut-out limit."); + + // RelayCommand.Execute can be invoked directly by non-UI callers, so the + // workflow itself must enforce the same guard as the button state. + viewModel.CommitSelectionCommand.Execute(null); + Require( + viewModel.Project.Cutouts.Count == WorkspacePolicy.MaxCutouts, + "A direct command invocation created a cut-out above the workspace limit."); + Require(viewModel.HasActiveSelection, "The blocked outline was discarded instead of remaining editable."); + } + private static void ActiveOutlineClearsCommittedSelection() { - var viewModel = new MainViewModel(); + var viewModel = CreateViewModel(); var cutout = CreateCutout( "Old cut-out", new PointF(5, 5), @@ -248,7 +360,7 @@ private static void ActiveOutlineClearsCommittedSelection() private static void SavedShapeSelectorsStaySynchronized() { - var viewModel = new MainViewModel(); + var viewModel = CreateViewModel(); var first = CreateSavedShape("First", 0); var second = CreateSavedShape("Second", 10); var third = CreateSavedShape("Third", 20); @@ -269,7 +381,7 @@ private static void SavedShapeSelectorsStaySynchronized() private static void ActiveOutlineSwitchIsUndoable() { - var viewModel = new MainViewModel(); + var viewModel = CreateViewModel(); var cutout = CreateCutout( "Committed", new PointF(5, 5), @@ -289,7 +401,7 @@ private static void ActiveOutlineSwitchIsUndoable() private static void MultipleCutoutsAreNotBrushTarget() { - var viewModel = new MainViewModel(); + var viewModel = CreateViewModel(); var first = CreateCutout( "First", new PointF(0, 0), @@ -314,7 +426,7 @@ private static void MultipleCutoutsAreNotBrushTarget() private static void SavedShapeRenameIsUndoable() { - var viewModel = new MainViewModel(); + var viewModel = CreateViewModel(); var shape = CreateSavedShape("Original Shape", 0); viewModel.Project.SavedShapes.Add(shape); viewModel.SelectedSavedShape = shape; @@ -331,7 +443,7 @@ private static void SavedShapeRenameIsUndoable() private static void CutoutRenameIsUndoable() { - var viewModel = new MainViewModel(); + var viewModel = CreateViewModel(); var cutout = CreateCutout( "Original Cut-out", new PointF(0, 0), @@ -353,7 +465,7 @@ private static void CutoutRenameIsUndoable() private static void ScrollBarTemplatePreservesControlState() { - var window = new MainWindow(); + var window = new MainWindow(CreateViewModel()); try { var style = window.TryFindResource(typeof(ScrollBar)) as Style; @@ -448,6 +560,61 @@ private static SKBitmap CreateSolidBitmap(int width, int height, SKColor color) return bitmap; } + private static MainViewModel CreateViewModel() + { + return new MainViewModel(new InMemorySettingsService()); + } + + private static void ViewModelsUseInjectedInMemorySettings() + { + var settings = new InMemorySettingsService(); + var viewModel = new MainViewModel(settings); + Require(settings.LoadCount == 1, "The injected settings store was not used during startup."); + + viewModel.IsLightMode = !viewModel.IsLightMode; + Require(settings.SaveCount > 0, "Preference changes bypassed the injected settings store."); + } + + private static void LongOperationsLockAndRestoreEditorCommands() + { + var viewModel = CreateViewModel(); + Require(viewModel.IsNotBusy, "A new editor started in a busy state."); + + Require( + viewModel.TryBeginOperation("Working…", CancellationToken.None, out var operationToken), + "The editor did not start a long operation."); + Require(viewModel.IsBusy && !viewModel.IsNotBusy, "The busy state did not lock the editor."); + Require(viewModel.CancelOperationCommand.CanExecute(null), "The cancellation command was not enabled."); + + viewModel.CancelCurrentOperation(); + Require(operationToken.IsCancellationRequested, "Cancellation was not forwarded to the active operation."); + viewModel.CompleteOperation(); + + Require(viewModel.IsNotBusy && !viewModel.IsBusy, "The editor did not leave the busy state."); + Require(!viewModel.CancelOperationCommand.CanExecute(null), "Cancellation stayed enabled after completion."); + } + + private sealed class InMemorySettingsService : ImageUiSlicer.Services.Contracts.ISettingsService + { + private AppSettings _settings = new(); + + public int LoadCount { get; private set; } + + public int SaveCount { get; private set; } + + public AppSettings Load() + { + LoadCount++; + return _settings; + } + + public void Save(AppSettings settings) + { + SaveCount++; + _settings = settings; + } + } + private static void Require(bool condition, string message) { if (!condition) diff --git a/solution/ImageUiSlicer/CanvasEngine/CanvasController.Drawing.cs b/solution/ImageUiSlicer/CanvasEngine/CanvasController.Drawing.cs index debe678..fa8ecc3 100644 --- a/solution/ImageUiSlicer/CanvasEngine/CanvasController.Drawing.cs +++ b/solution/ImageUiSlicer/CanvasEngine/CanvasController.Drawing.cs @@ -1,6 +1,8 @@ using System.Windows; using System.Windows.Input; using ImageUiSlicer.Models; +using ImageUiSlicer.CanvasEngine.Freehand; +using SkiaSharp; namespace ImageUiSlicer.CanvasEngine; @@ -10,6 +12,7 @@ public sealed partial class CanvasController private bool _isFreehandDrawing; private readonly List _freehandPoints = new(); + private PointF? _freehandLivePoint; private readonly List _polygonPoints = new(); public bool HasPendingPolygon => _viewModel?.ActiveTool == CanvasTool.Polygon && _polygonPoints.Count > 0; @@ -24,7 +27,9 @@ private void BeginFreehand(Point position) } _freehandPoints.Clear(); - _freehandPoints.Add(ClampToImage(imagePoint)); + var startPoint = ClampToImage(imagePoint); + _freehandPoints.Add(startPoint); + _freehandLivePoint = startPoint; _isFreehandDrawing = true; } @@ -34,15 +39,20 @@ private void EndFreehand(Point position) if (TryScreenToImage(position, out var imagePoint)) { imagePoint = ClampToImage(imagePoint); - if (_freehandPoints.Count == 0 || Distance(_freehandPoints[^1], imagePoint) >= 1.5f) - { - _freehandPoints.Add(imagePoint); - } + _freehandLivePoint = imagePoint; + FreehandPathProcessor.TryAddPoint( + _freehandPoints, + imagePoint, + _viewModel!.FreehandResponse, + _viewModel.FreehandSmoothing, + GetUiScale(), + force: true); } if (_freehandPoints.Count < 3) { _freehandPoints.Clear(); + _freehandLivePoint = null; _viewModel!.StatusText = "Selection too small."; return; } @@ -55,6 +65,36 @@ private void EndFreehand(Point position) }); _viewModel.StatusText = "Draw Around selection is ready. Check the edge, then choose Save Cut-out."; _freehandPoints.Clear(); + _freehandLivePoint = null; + } + + private void CancelFreehandGesture() + { + _isFreehandDrawing = false; + _freehandPoints.Clear(); + _freehandLivePoint = null; + } + + private bool AppendFreehandGuidePath(SKPath path) + { + if (_freehandPoints.Count == 0) + { + return false; + } + + path.MoveTo(_freehandPoints[0].X, _freehandPoints[0].Y); + for (var index = 1; index < _freehandPoints.Count; index++) + { + path.LineTo(_freehandPoints[index].X, _freehandPoints[index].Y); + } + + if (_freehandLivePoint is PointF livePoint && livePoint != _freehandPoints[^1]) + { + path.LineTo(livePoint.X, livePoint.Y); + } + + return _freehandPoints.Count > 1 || + (_freehandLivePoint is PointF pointer && pointer != _freehandPoints[0]); } private void AddPolygonPoint(Point position, int clickCount) diff --git a/solution/ImageUiSlicer/CanvasEngine/CanvasController.Input.cs b/solution/ImageUiSlicer/CanvasEngine/CanvasController.Input.cs index 4f0dcb0..5c1d85c 100644 --- a/solution/ImageUiSlicer/CanvasEngine/CanvasController.Input.cs +++ b/solution/ImageUiSlicer/CanvasEngine/CanvasController.Input.cs @@ -13,8 +13,7 @@ public void NotifyToolChanged() if (_viewModel?.ActiveTool != CanvasTool.Lasso) { - _freehandPoints.Clear(); - _isFreehandDrawing = false; + CancelFreehandGesture(); } if (_viewModel?.ActiveTool != CanvasTool.Shape) @@ -50,6 +49,13 @@ public bool HandleKey(Key key) return true; } + if (_isFreehandDrawing) + { + CancelFreehandGesture(); + _viewModel.StatusText = "Draw Around line cleared."; + return true; + } + if (_polygonPoints.Count > 0) { return UndoPendingPolygonPoint(); @@ -77,14 +83,14 @@ public bool HandleKey(Key key) if (key == Key.Add || key == Key.OemPlus) { - ZoomAtViewportCenter(1.15f); + ZoomAtViewportCenter(ViewportOptions.ZoomStep); _viewModel.StatusText = "Zoom changed for tracing only. Export resolution stays original."; return true; } if (key == Key.Subtract || key == Key.OemMinus) { - ZoomAtViewportCenter(1f / 1.15f); + ZoomAtViewportCenter(1f / ViewportOptions.ZoomStep); _viewModel.StatusText = "Zoom changed for tracing only. Export resolution stays original."; return true; } diff --git a/solution/ImageUiSlicer/CanvasEngine/CanvasController.Magnifier.cs b/solution/ImageUiSlicer/CanvasEngine/CanvasController.Magnifier.cs index 6051fc9..b39656b 100644 --- a/solution/ImageUiSlicer/CanvasEngine/CanvasController.Magnifier.cs +++ b/solution/ImageUiSlicer/CanvasEngine/CanvasController.Magnifier.cs @@ -40,7 +40,10 @@ private void DrawPrecisionMagnifier(SKCanvas canvas, int viewportWidth, int view lensX = Math.Clamp(lensX, MagnifierRadius + 8f, viewportWidth - MagnifierRadius - 8f); lensY = Math.Clamp(lensY, MagnifierRadius + 8f, viewportHeight - MagnifierRadius - 8f); - var magnifierZoom = Math.Clamp(_viewModel.PrecisionMagnifierZoom, 5, 10); + var magnifierZoom = Math.Clamp( + _viewModel.PrecisionMagnifierZoom, + ViewportOptions.MinimumMagnifierZoom, + ViewportOptions.MaximumMagnifierZoom); var sourceRadius = MagnifierRadius / magnifierZoom; var sourceRect = SKRect.Create( _hoverImagePoint.X - sourceRadius, @@ -157,13 +160,9 @@ private void DrawMagnifierCutLines(SKCanvas canvas, float lensX, float lensY, fl guidePath.AddPath(activePath); } - if (_viewModel.ActiveTool == CanvasTool.Lasso && _freehandPoints.Count > 1) + if (_viewModel.ActiveTool == CanvasTool.Lasso) { - guidePath.MoveTo(_freehandPoints[0].X, _freehandPoints[0].Y); - for (var index = 1; index < _freehandPoints.Count; index++) - { - guidePath.LineTo(_freehandPoints[index].X, _freehandPoints[index].Y); - } + AppendFreehandGuidePath(guidePath); } if (_viewModel.ActiveTool == CanvasTool.Polygon && _polygonPoints.Count > 0) diff --git a/solution/ImageUiSlicer/CanvasEngine/CanvasController.PointerInput.cs b/solution/ImageUiSlicer/CanvasEngine/CanvasController.PointerInput.cs index ce78efa..dd2023a 100644 --- a/solution/ImageUiSlicer/CanvasEngine/CanvasController.PointerInput.cs +++ b/solution/ImageUiSlicer/CanvasEngine/CanvasController.PointerInput.cs @@ -1,5 +1,6 @@ using System.Windows; using System.Windows.Input; +using ImageUiSlicer.CanvasEngine.Freehand; namespace ImageUiSlicer.CanvasEngine; @@ -173,10 +174,14 @@ public void OnMouseMove(Point position, bool leftButtonPressed, bool panButtonPr } imagePoint = ClampToImage(imagePoint); - if (_freehandPoints.Count == 0 || Distance(_freehandPoints[^1], imagePoint) >= 1.5f) - { - _freehandPoints.Add(imagePoint); - } + _freehandLivePoint = imagePoint; + FreehandPathProcessor.TryAddPoint( + _freehandPoints, + imagePoint, + _viewModel.FreehandResponse, + _viewModel.FreehandSmoothing, + GetUiScale(), + precisionOverride: modifiers.HasFlag(ModifierKeys.Shift)); } } @@ -245,6 +250,7 @@ public void OnMouseUp(Point position) public void OnMouseLeave() { + var drawingWasCancelled = _isFreehandDrawing || _isShapeDrawing; _hasHoverImagePoint = false; _isPanning = false; _isSelectPanCandidate = false; @@ -255,5 +261,11 @@ public void OnMouseLeave() ResetActiveSelectionMove(); EndBrushRefine(); + CancelFreehandGesture(); + _isShapeDrawing = false; + if (drawingWasCancelled && _viewModel is not null) + { + _viewModel.StatusText = "Drawing cancelled because pointer control was interrupted."; + } } } diff --git a/solution/ImageUiSlicer/CanvasEngine/CanvasController.RenderingGuides.cs b/solution/ImageUiSlicer/CanvasEngine/CanvasController.RenderingGuides.cs index 73db4ea..a034bca 100644 --- a/solution/ImageUiSlicer/CanvasEngine/CanvasController.RenderingGuides.cs +++ b/solution/ImageUiSlicer/CanvasEngine/CanvasController.RenderingGuides.cs @@ -15,7 +15,7 @@ private void DrawInProgressSelection(SKCanvas canvas) var uiScale = GetUiScale(); - if (_viewModel.ActiveTool == CanvasTool.Lasso && _freehandPoints.Count > 1) + if (_viewModel.ActiveTool == CanvasTool.Lasso) { using var stroke = new SKPaint { @@ -25,13 +25,10 @@ private void DrawInProgressSelection(SKCanvas canvas) Color = ResolveCutLineColor(), }; using var path = new SKPath(); - path.MoveTo(_freehandPoints[0].X, _freehandPoints[0].Y); - for (var index = 1; index < _freehandPoints.Count; index++) + if (AppendFreehandGuidePath(path)) { - path.LineTo(_freehandPoints[index].X, _freehandPoints[index].Y); + canvas.DrawPath(path, stroke); } - - canvas.DrawPath(path, stroke); } if (_viewModel.ActiveTool == CanvasTool.Polygon && _polygonPoints.Count > 0) diff --git a/solution/ImageUiSlicer/CanvasEngine/CanvasController.Viewport.cs b/solution/ImageUiSlicer/CanvasEngine/CanvasController.Viewport.cs index 498678b..7ae204b 100644 --- a/solution/ImageUiSlicer/CanvasEngine/CanvasController.Viewport.cs +++ b/solution/ImageUiSlicer/CanvasEngine/CanvasController.Viewport.cs @@ -7,8 +7,6 @@ namespace ImageUiSlicer.CanvasEngine; public sealed partial class CanvasController { - private const float MinScale = 0.001f; - private const float MaxScale = 32f; private const double SelectPanThreshold = 6d; private float _viewportWidth; @@ -45,9 +43,9 @@ public void ZoomActualPixels() _viewModel.SetCanvasView(1, 0, 0); } - public void ZoomIn() => ZoomAtViewportCenter(1.2f); + public void ZoomIn() => ZoomAtViewportCenter(ViewportOptions.ZoomStep); - public void ZoomOut() => ZoomAtViewportCenter(1f / 1.2f); + public void ZoomOut() => ZoomAtViewportCenter(1f / ViewportOptions.ZoomStep); public void OnMouseWheel(Point position, int delta) { @@ -61,9 +59,9 @@ public void OnMouseWheel(Point position, int delta) focusPoint = new PointF(_viewModel.SourceBitmap.Width / 2f, _viewModel.SourceBitmap.Height / 2f); } - var scaleFactor = delta > 0 ? 1.1f : 1f / 1.1f; - var currentScale = Math.Max(MinScale, (float)(_viewModel.CanvasScale <= 0 ? ComputeFitScale(_viewModel.SourceBitmap.Width, _viewModel.SourceBitmap.Height) : _viewModel.CanvasScale)); - var newScale = Math.Clamp(currentScale * scaleFactor, MinScale, MaxScale); + var scaleFactor = delta > 0 ? ViewportOptions.ZoomStep : 1f / ViewportOptions.ZoomStep; + var currentScale = Math.Max(ViewportOptions.MinimumScale, (float)(_viewModel.CanvasScale <= 0 ? ComputeFitScale(_viewModel.SourceBitmap.Width, _viewModel.SourceBitmap.Height) : _viewModel.CanvasScale)); + var newScale = Math.Clamp(currentScale * scaleFactor, ViewportOptions.MinimumScale, ViewportOptions.MaximumScale); var offsetBefore = GetImageOffset(currentScale, _viewModel.PanX, _viewModel.PanY, _viewModel.SourceBitmap.Width, _viewModel.SourceBitmap.Height); var screenX = offsetBefore.X + (focusPoint.X * currentScale); @@ -180,7 +178,7 @@ private void ZoomAtImagePoint(PointF focusPoint, float scaleFactor) } var currentScale = GetUiScale(); - var newScale = Math.Clamp(currentScale * scaleFactor, MinScale, MaxScale); + var newScale = Math.Clamp(currentScale * scaleFactor, ViewportOptions.MinimumScale, ViewportOptions.MaximumScale); var offsetBefore = GetImageOffset(currentScale, _viewModel.PanX, _viewModel.PanY, _viewModel.SourceBitmap.Width, _viewModel.SourceBitmap.Height); var screenX = offsetBefore.X + (focusPoint.X * currentScale); var screenY = offsetBefore.Y + (focusPoint.Y * currentScale); @@ -195,7 +193,7 @@ private void ZoomAtImagePoint(PointF focusPoint, float scaleFactor) private float GetUiScale() { - return (float)Math.Max(_viewModel?.CanvasScale ?? 1, MinScale); + return (float)Math.Max(_viewModel?.CanvasScale ?? 1, ViewportOptions.MinimumScale); } private SKPoint GetImageOffset(float scale, double panX, double panY, int imageWidth, int imageHeight) diff --git a/solution/ImageUiSlicer/CanvasEngine/CanvasController.cs b/solution/ImageUiSlicer/CanvasEngine/CanvasController.cs index aa13b7b..df77bf1 100644 --- a/solution/ImageUiSlicer/CanvasEngine/CanvasController.cs +++ b/solution/ImageUiSlicer/CanvasEngine/CanvasController.cs @@ -11,7 +11,7 @@ public sealed partial class CanvasController public void Attach(ICanvasEditorContext? viewModel) { _viewModel = viewModel; - _freehandPoints.Clear(); + CancelFreehandGesture(); _polygonPoints.Clear(); _isShapeDrawing = false; _hasHoverImagePoint = false; diff --git a/solution/ImageUiSlicer/CanvasEngine/Contracts/ICanvasEditorContext.cs b/solution/ImageUiSlicer/CanvasEngine/Contracts/ICanvasEditorContext.cs index a59fbd4..cecbdb7 100644 --- a/solution/ImageUiSlicer/CanvasEngine/Contracts/ICanvasEditorContext.cs +++ b/solution/ImageUiSlicer/CanvasEngine/Contracts/ICanvasEditorContext.cs @@ -37,6 +37,10 @@ public interface ICanvasEditorState int PrecisionMagnifierZoom { get; } + FreehandResponse FreehandResponse { get; } + + FreehandSmoothing FreehandSmoothing { get; } + int RefineBrushSize { get; } double CanvasScale { get; } diff --git a/solution/ImageUiSlicer/CanvasEngine/Freehand/FreehandPathProcessor.cs b/solution/ImageUiSlicer/CanvasEngine/Freehand/FreehandPathProcessor.cs new file mode 100644 index 0000000..729c3e5 --- /dev/null +++ b/solution/ImageUiSlicer/CanvasEngine/Freehand/FreehandPathProcessor.cs @@ -0,0 +1,75 @@ +using ImageUiSlicer.Models; + +namespace ImageUiSlicer.CanvasEngine.Freehand; + +public static class FreehandPathProcessor +{ + public static bool TryAddPoint( + IList points, + PointF point, + FreehandResponse response, + FreehandSmoothing smoothing, + float viewScale = 1f, + bool precisionOverride = false, + bool force = false) + { + if (points.Count == 0) + { + points.Add(point); + return true; + } + + var effectiveResponse = precisionOverride ? FreehandResponse.Precise : response; + var minimumSpacing = effectiveResponse switch + { + FreehandResponse.Precise => 0.35f, + FreehandResponse.Direct => 0.15f, + _ => 1.5f, + }; + + // Response is a screen-space preference. Converting the requested + // spacing back into source-image pixels keeps the pen equally + // responsive at Fit, 1x, 5x, 10x, and every scale in between. + var safeViewScale = float.IsFinite(viewScale) && viewScale > 0.001f + ? viewScale + : 1f; + var minimumImageSpacing = minimumSpacing / safeViewScale; + + var distanceFromLastPoint = Distance(points[^1], point); + if (distanceFromLastPoint < 0.001f || (!force && distanceFromLastPoint < minimumImageSpacing)) + { + return false; + } + + var effectiveSmoothing = precisionOverride ? FreehandSmoothing.High : smoothing; + var smoothingStrength = effectiveSmoothing switch + { + FreehandSmoothing.High => 0.45f, + FreehandSmoothing.Medium => 0.25f, + _ => 0f, + }; + + // Smooth the previous interior point while keeping the newest point exactly + // beneath the pointer. This steadies the outline without cursor-to-line lag. + if (smoothingStrength > 0f && points.Count >= 2) + { + var previous = points[^1]; + var midpoint = new PointF( + (points[^2].X + point.X) * 0.5f, + (points[^2].Y + point.Y) * 0.5f); + points[^1] = new PointF( + previous.X + ((midpoint.X - previous.X) * smoothingStrength), + previous.Y + ((midpoint.Y - previous.Y) * smoothingStrength)); + } + + points.Add(point); + return true; + } + + private static float Distance(PointF first, PointF second) + { + var dx = first.X - second.X; + var dy = first.Y - second.Y; + return MathF.Sqrt((dx * dx) + (dy * dy)); + } +} diff --git a/solution/ImageUiSlicer/CanvasEngine/Viewport/ViewportOptions.cs b/solution/ImageUiSlicer/CanvasEngine/Viewport/ViewportOptions.cs new file mode 100644 index 0000000..2182e41 --- /dev/null +++ b/solution/ImageUiSlicer/CanvasEngine/Viewport/ViewportOptions.cs @@ -0,0 +1,16 @@ +namespace ImageUiSlicer.CanvasEngine; + +public static class ViewportOptions +{ + public const float MinimumScale = 0.001f; + + public const float MaximumScale = 32f; + + public const float ZoomStep = 1.1f; + + public const int MinimumMagnifierZoom = 5; + + public const int MaximumMagnifierZoom = 10; + + public static IReadOnlyList MagnifierZoomLevels { get; } = [5, 6, 7, 8, 10]; +} diff --git a/solution/ImageUiSlicer/Core/Geometry/GeometryHelper.cs b/solution/ImageUiSlicer/Core/Geometry/GeometryHelper.cs index 28a0bef..91631c7 100644 --- a/solution/ImageUiSlicer/Core/Geometry/GeometryHelper.cs +++ b/solution/ImageUiSlicer/Core/Geometry/GeometryHelper.cs @@ -112,19 +112,27 @@ public static bool TryGetImageCrop( var maxX = points.Max(point => (double)point.X); var maxY = points.Max(point => (double)point.Y); - // Clamp in floating-point space before converting to int. This keeps - // malformed but finite extreme coordinates from overflowing conversion. - var left = (int)Math.Floor(Math.Clamp(minX - padding, 0d, imageWidth)); - var top = (int)Math.Floor(Math.Clamp(minY - padding, 0d, imageHeight)); - var right = (int)Math.Ceiling(Math.Clamp(maxX + padding, 0d, imageWidth)); - var bottom = (int)Math.Ceiling(Math.Clamp(maxY + padding, 0d, imageHeight)); - - if (right <= left || bottom <= top) + // The visible geometry is bounded by the source image, but requested + // padding belongs to the output canvas. Allowing the padded crop to + // extend beyond the source preserves a transparent border even when a + // cut-out touches an image edge. + var visibleMinX = Math.Clamp(minX, 0d, imageWidth); + var visibleMinY = Math.Clamp(minY, 0d, imageHeight); + var visibleMaxX = Math.Clamp(maxX, 0d, imageWidth); + var visibleMaxY = Math.Clamp(maxY, 0d, imageHeight); + var left = SaturatingInt(Math.Floor(visibleMinX - padding)); + var top = SaturatingInt(Math.Floor(visibleMinY - padding)); + var right = SaturatingInt(Math.Ceiling(visibleMaxX + padding)); + var bottom = SaturatingInt(Math.Ceiling(visibleMaxY + padding)); + + var width = (long)right - left; + var height = (long)bottom - top; + if (width <= 0 || height <= 0 || width > int.MaxValue || height > int.MaxValue) { return false; } - crop = new BBox(left, top, right - left, bottom - top); + crop = new BBox(left, top, (int)width, (int)height); return true; } diff --git a/solution/ImageUiSlicer/Core/Images/ImageFormatCatalog.cs b/solution/ImageUiSlicer/Core/Images/ImageFormatCatalog.cs new file mode 100644 index 0000000..d3efef6 --- /dev/null +++ b/solution/ImageUiSlicer/Core/Images/ImageFormatCatalog.cs @@ -0,0 +1,24 @@ +namespace ImageUiSlicer.Core.Images; + +public static class ImageFormatCatalog +{ + private static readonly HashSet SupportedExtensions = + [ + ".png", + ".jpg", + ".jpeg", + ".bmp", + ".gif", + ".webp", + ]; + + public const string DialogPattern = "*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp"; + + public const string FriendlyList = "PNG, JPG/JPEG, BMP, GIF and WEBP"; + + public static bool SupportsFile(string? path) + { + return !string.IsNullOrWhiteSpace(path) && + SupportedExtensions.Contains(Path.GetExtension(path).ToLowerInvariant()); + } +} diff --git a/solution/ImageUiSlicer/ImageUiSlicer.csproj b/solution/ImageUiSlicer/ImageUiSlicer.csproj index c91846f..ec3a903 100644 --- a/solution/ImageUiSlicer/ImageUiSlicer.csproj +++ b/solution/ImageUiSlicer/ImageUiSlicer.csproj @@ -11,10 +11,10 @@ Photo Cutter Photo Cutter Turn screenshots and images into clean, transparent PNG cut-outs. - 1.2.0 - 1.2.0.0 - 1.2.0.0 - 1.2.0 + 1.3.0 + 1.3.0.0 + 1.3.0.0 + 1.3.0 false Awetspoon https://github.com/Awetspoon/Photo-Cutter diff --git a/solution/ImageUiSlicer/Infrastructure/AsyncRelayCommand.cs b/solution/ImageUiSlicer/Infrastructure/AsyncRelayCommand.cs new file mode 100644 index 0000000..aadc469 --- /dev/null +++ b/solution/ImageUiSlicer/Infrastructure/AsyncRelayCommand.cs @@ -0,0 +1,80 @@ +using System.Windows.Input; + +namespace ImageUiSlicer.Infrastructure; + +public sealed class AsyncRelayCommand : ICommand +{ + private readonly Func _execute; + private readonly Func? _canExecute; + private CancellationTokenSource? _executionCancellation; + private bool _isRunning; + + public AsyncRelayCommand( + Func execute, + Func? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + public bool IsRunning => _isRunning; + + public bool CanExecute(object? parameter) => + !_isRunning && (_canExecute?.Invoke() ?? true); + + public async void Execute(object? parameter) + { + try + { + await ExecuteAsync(); + } + catch (OperationCanceledException) + { + // Cancellation is an expected user action. + } + catch (Exception ex) + { + ExecutionFailed?.Invoke(this, ex); + } + } + + public async Task ExecuteAsync() + { + if (!CanExecute(null)) + { + return; + } + + _executionCancellation = new CancellationTokenSource(); + SetIsRunning(true); + try + { + await _execute(_executionCancellation.Token); + } + finally + { + _executionCancellation.Dispose(); + _executionCancellation = null; + SetIsRunning(false); + } + } + + public void Cancel() => _executionCancellation?.Cancel(); + + public event EventHandler? CanExecuteChanged; + + public event EventHandler? ExecutionFailed; + + public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); + + private void SetIsRunning(bool value) + { + if (_isRunning == value) + { + return; + } + + _isRunning = value; + RaiseCanExecuteChanged(); + } +} diff --git a/solution/ImageUiSlicer/Models/AppSettings.cs b/solution/ImageUiSlicer/Models/AppSettings.cs index 8132e9f..0609116 100644 --- a/solution/ImageUiSlicer/Models/AppSettings.cs +++ b/solution/ImageUiSlicer/Models/AppSettings.cs @@ -31,4 +31,8 @@ public sealed class AppSettings public bool LastLightMode { get; set; } public bool LastSmoothZoomPreview { get; set; } = true; + + public string LastFreehandResponse { get; set; } = nameof(FreehandResponse.Balanced); + + public string LastFreehandSmoothing { get; set; } = nameof(FreehandSmoothing.Medium); } diff --git a/solution/ImageUiSlicer/Models/FreehandSettings.cs b/solution/ImageUiSlicer/Models/FreehandSettings.cs new file mode 100644 index 0000000..66007a8 --- /dev/null +++ b/solution/ImageUiSlicer/Models/FreehandSettings.cs @@ -0,0 +1,15 @@ +namespace ImageUiSlicer.Models; + +public enum FreehandResponse +{ + Precise, + Balanced, + Direct, +} + +public enum FreehandSmoothing +{ + Low, + Medium, + High, +} diff --git a/solution/ImageUiSlicer/Models/ProjectExportSettingsModel.cs b/solution/ImageUiSlicer/Models/ProjectExportSettingsModel.cs new file mode 100644 index 0000000..c0dd14e --- /dev/null +++ b/solution/ImageUiSlicer/Models/ProjectExportSettingsModel.cs @@ -0,0 +1,10 @@ +namespace ImageUiSlicer.Models; + +public sealed class ProjectExportSettingsModel +{ + public string Preset { get; set; } = "custom"; + + public string NamingMode { get; set; } = "autoNumbered"; + + public string Prefix { get; set; } = string.Empty; +} diff --git a/solution/ImageUiSlicer/Models/ProjectModel.cs b/solution/ImageUiSlicer/Models/ProjectModel.cs index e7742ac..8c7416f 100644 --- a/solution/ImageUiSlicer/Models/ProjectModel.cs +++ b/solution/ImageUiSlicer/Models/ProjectModel.cs @@ -12,6 +12,7 @@ public sealed class ProjectModel : ObservableObject private DateTime _modifiedUtc = DateTime.UtcNow; private SourceImageModel _sourceImage = new(); private ExportOptionsModel _defaults = new(); + private ProjectExportSettingsModel _exportSettings = new(); private bool _showCutoutsOverlay = true; private ObservableCollection _cutouts = new(); private ObservableCollection _savedShapes = new(); @@ -53,6 +54,12 @@ public ExportOptionsModel Defaults set => SetProperty(ref _defaults, value); } + public ProjectExportSettingsModel ExportSettings + { + get => _exportSettings; + set => SetProperty(ref _exportSettings, value); + } + public bool ShowCutoutsOverlay { get => _showCutoutsOverlay; diff --git a/solution/ImageUiSlicer/Models/SourceImageModel.cs b/solution/ImageUiSlicer/Models/SourceImageModel.cs index 3a3034d..fb59a4c 100644 --- a/solution/ImageUiSlicer/Models/SourceImageModel.cs +++ b/solution/ImageUiSlicer/Models/SourceImageModel.cs @@ -7,6 +7,8 @@ public sealed class SourceImageModel : ObservableObject private string _path = string.Empty; private int _pixelWidth; private int _pixelHeight; + private long _fileSizeBytes; + private string _contentSha256 = string.Empty; public string Path { @@ -25,4 +27,16 @@ public int PixelHeight get => _pixelHeight; set => SetProperty(ref _pixelHeight, value); } + + public long FileSizeBytes + { + get => _fileSizeBytes; + set => SetProperty(ref _fileSizeBytes, value); + } + + public string ContentSha256 + { + get => _contentSha256; + set => SetProperty(ref _contentSha256, value); + } } diff --git a/solution/ImageUiSlicer/Presentation/Preview/CutoutPreviewFactory.cs b/solution/ImageUiSlicer/Presentation/Preview/CutoutPreviewFactory.cs index 746adb6..9084db1 100644 --- a/solution/ImageUiSlicer/Presentation/Preview/CutoutPreviewFactory.cs +++ b/solution/ImageUiSlicer/Presentation/Preview/CutoutPreviewFactory.cs @@ -7,9 +7,9 @@ namespace ImageUiSlicer.Presentation.Preview; -public sealed class CutoutPreviewFactory +public sealed class CutoutPreviewFactory : ICutoutPreviewFactory { - private const int ThumbnailLongestEdge = 384; + private const int ThumbnailLongestEdge = 128; private readonly CutoutRenderService _cutoutRenderService = new(); @@ -20,22 +20,49 @@ public sealed class CutoutPreviewFactory return null; } - var previewCutout = cutout.DeepClone(); - previewCutout.Export.Scale = 1; - previewCutout.Export.TargetPixelSize = ThumbnailLongestEdge; - using var rendered = _cutoutRenderService.RenderCutoutBitmap(sourceBitmap, previewCutout); - return ToBitmapSource(rendered); + try + { + var previewCutout = cutout.DeepClone(); + previewCutout.Export.Scale = 1; + previewCutout.Export.Padding = ExportOptionsPolicy.NormalizePadding(previewCutout.Export.Padding); + if (!GeometryHelper.TryGetImageCrop( + previewCutout.Geometry, + sourceBitmap.Width, + sourceBitmap.Height, + previewCutout.Export.Padding, + out var crop)) + { + return null; + } + + previewCutout.Export.TargetPixelSize = Math.Min( + ThumbnailLongestEdge, + Math.Max(crop.W, crop.H)); + using var rendered = _cutoutRenderService.RenderCutoutBitmap(sourceBitmap, previewCutout); + return ToBitmapSource(rendered); + } + catch (Exception ex) when (IsRecoverablePreviewFailure(ex)) + { + return null; + } } public BitmapSource? BuildInspectorPreview(SKBitmap sourceBitmap, CutoutModel cutout, bool splitPreview, double splitRatio) { - using var preview = _cutoutRenderService.RenderInspectorPreviewBitmap(sourceBitmap, cutout, splitPreview, splitRatio); - if (preview is null) + try + { + using var preview = _cutoutRenderService.RenderInspectorPreviewBitmap(sourceBitmap, cutout, splitPreview, splitRatio); + if (preview is null) + { + return null; + } + + return ToBitmapSource(preview); + } + catch (Exception ex) when (IsRecoverablePreviewFailure(ex)) { return null; } - - return ToBitmapSource(preview); } public IReadOnlyList BuildItems(SKBitmap sourceBitmap, IEnumerable cutouts) @@ -77,4 +104,9 @@ private static BitmapSource ToBitmapSource(SKBitmap bitmap) preview.Freeze(); return preview; } + + private static bool IsRecoverablePreviewFailure(Exception exception) + { + return exception is InvalidOperationException or ArgumentException or OverflowException; + } } diff --git a/solution/ImageUiSlicer/Presentation/Preview/ICutoutPreviewFactory.cs b/solution/ImageUiSlicer/Presentation/Preview/ICutoutPreviewFactory.cs new file mode 100644 index 0000000..bc232d1 --- /dev/null +++ b/solution/ImageUiSlicer/Presentation/Preview/ICutoutPreviewFactory.cs @@ -0,0 +1,20 @@ +using System.Windows.Media.Imaging; +using ImageUiSlicer.Models; +using SkiaSharp; + +namespace ImageUiSlicer.Presentation.Preview; + +public interface ICutoutPreviewFactory +{ + BitmapSource? BuildPreviewImage(SKBitmap sourceBitmap, CutoutModel cutout); + + BitmapSource? BuildInspectorPreview( + SKBitmap sourceBitmap, + CutoutModel cutout, + bool splitPreview, + double splitRatio); + + IReadOnlyList BuildItems( + SKBitmap sourceBitmap, + IEnumerable cutouts); +} diff --git a/solution/ImageUiSlicer/Services/Contracts/IImageService.cs b/solution/ImageUiSlicer/Services/Contracts/IImageService.cs new file mode 100644 index 0000000..e5900d3 --- /dev/null +++ b/solution/ImageUiSlicer/Services/Contracts/IImageService.cs @@ -0,0 +1,12 @@ +using SkiaSharp; + +namespace ImageUiSlicer.Services.Contracts; + +public interface IImageService +{ + bool IsSupportedImage(string path); + + SKBitmap LoadBitmap(string path); + + SourceImageFingerprint ReadFingerprint(string path); +} diff --git a/solution/ImageUiSlicer/Services/Contracts/IProjectService.cs b/solution/ImageUiSlicer/Services/Contracts/IProjectService.cs new file mode 100644 index 0000000..714f636 --- /dev/null +++ b/solution/ImageUiSlicer/Services/Contracts/IProjectService.cs @@ -0,0 +1,12 @@ +using ImageUiSlicer.Models; + +namespace ImageUiSlicer.Services.Contracts; + +public interface IProjectService +{ + ProjectModel Load(string path); + + ProjectLoadResult LoadWithReport(string path); + + void Save(ProjectModel project, string path); +} diff --git a/solution/ImageUiSlicer/Services/Contracts/ISettingsService.cs b/solution/ImageUiSlicer/Services/Contracts/ISettingsService.cs new file mode 100644 index 0000000..be087c2 --- /dev/null +++ b/solution/ImageUiSlicer/Services/Contracts/ISettingsService.cs @@ -0,0 +1,10 @@ +using ImageUiSlicer.Models; + +namespace ImageUiSlicer.Services.Contracts; + +public interface ISettingsService +{ + AppSettings Load(); + + void Save(AppSettings settings); +} diff --git a/solution/ImageUiSlicer/Services/CutoutRenderService.cs b/solution/ImageUiSlicer/Services/CutoutRenderService.cs index 8a6682c..c2bf711 100644 --- a/solution/ImageUiSlicer/Services/CutoutRenderService.cs +++ b/solution/ImageUiSlicer/Services/CutoutRenderService.cs @@ -139,7 +139,11 @@ private static SKBitmap RenderSourceCrop(SKBitmap sourceBitmap, SKRectI cropRect using var canvas = new SKCanvas(before); canvas.Clear(SKColors.Transparent); using var paint = new SKPaint { FilterQuality = SKFilterQuality.Medium, IsAntialias = true }; - canvas.DrawBitmap(sourceBitmap, cropRect, new SKRectI(0, 0, targetWidth, targetHeight), paint); + canvas.Scale( + targetWidth / (float)Math.Max(1, cropRect.Width), + targetHeight / (float)Math.Max(1, cropRect.Height)); + canvas.Translate(-cropRect.Left, -cropRect.Top); + canvas.DrawBitmap(sourceBitmap, 0, 0, paint); canvas.Flush(); return before; } @@ -264,7 +268,14 @@ private static bool TryGetCropRect(CutoutModel cutout, SKBitmap sourceBitmap, ou return false; } - cropRect = new SKRectI(crop.X, crop.Y, crop.X + crop.W, crop.Y + crop.H); + var right = (long)crop.X + crop.W; + var bottom = (long)crop.Y + crop.H; + if (right > int.MaxValue || bottom > int.MaxValue) + { + return false; + } + + cropRect = new SKRectI(crop.X, crop.Y, (int)right, (int)bottom); return true; } diff --git a/solution/ImageUiSlicer/Services/ExportOptionsPolicy.cs b/solution/ImageUiSlicer/Services/ExportOptionsPolicy.cs index a65466f..1f0441f 100644 --- a/solution/ImageUiSlicer/Services/ExportOptionsPolicy.cs +++ b/solution/ImageUiSlicer/Services/ExportOptionsPolicy.cs @@ -3,6 +3,8 @@ namespace ImageUiSlicer.Services; public static class ExportOptionsPolicy { public const int DefaultTargetPixelSize = 256; + public const int MaxPadding = 4096; + public const int MaxFilePrefixLength = 120; public static int NormalizeTargetPixelSize(int targetPixelSize) { @@ -20,4 +22,24 @@ public static string NormalizeOutlineMode(string? outlineMode) _ => "none", }; } + + public static int NormalizePadding(int padding) + { + return Math.Clamp(padding, 0, MaxPadding); + } + + public static string NormalizeNamingMode(string? namingMode) + { + return string.Equals(namingMode?.Trim(), "cutoutNames", StringComparison.OrdinalIgnoreCase) + ? "cutoutNames" + : "autoNumbered"; + } + + public static string NormalizeFilePrefix(string? prefix) + { + var normalized = prefix ?? string.Empty; + return normalized.Length <= MaxFilePrefixLength + ? normalized + : normalized[..MaxFilePrefixLength]; + } } diff --git a/solution/ImageUiSlicer/Services/ExportService.cs b/solution/ImageUiSlicer/Services/ExportService.cs index 7fbc634..cdc039e 100644 --- a/solution/ImageUiSlicer/Services/ExportService.cs +++ b/solution/ImageUiSlicer/Services/ExportService.cs @@ -8,6 +8,23 @@ public sealed class ExportService private readonly CutoutRenderService _cutoutRenderService = new(); public string ExportCutout(SKBitmap sourceBitmap, CutoutModel cutout, string baseFolder, string fileNoExt) + { + return ExportCutoutAsync( + sourceBitmap, + cutout, + baseFolder, + fileNoExt, + CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + + public async Task ExportCutoutAsync( + SKBitmap sourceBitmap, + CutoutModel cutout, + string baseFolder, + string fileNoExt, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(sourceBitmap); ArgumentNullException.ThrowIfNull(cutout); @@ -17,9 +34,12 @@ public string ExportCutout(SKBitmap sourceBitmap, CutoutModel cutout, string bas throw new ArgumentException("Choose an export folder first.", nameof(baseFolder)); } + cancellationToken.ThrowIfCancellationRequested(); + // Render and encode before touching the destination. Invalid or // off-image geometry therefore cannot leave an empty output file. using var rendered = _cutoutRenderService.RenderCutoutBitmap(sourceBitmap, cutout); + cancellationToken.ThrowIfCancellationRequested(); using var image = SKImage.FromBitmap(rendered); using var data = image.Encode(SKEncodedImageFormat.Png, 100); if (data is null) @@ -27,31 +47,24 @@ public string ExportCutout(SKBitmap sourceBitmap, CutoutModel cutout, string bas throw new InvalidOperationException("The cut-out could not be encoded as a PNG."); } + cancellationToken.ThrowIfCancellationRequested(); var fullBaseFolder = Path.GetFullPath(baseFolder); Directory.CreateDirectory(fullBaseFolder); var temporaryPath = Path.Combine(fullBaseFolder, $".photo-cutter-{Guid.NewGuid():N}.tmp"); try { - using (var stream = new FileStream( - temporaryPath, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - bufferSize: 81920, - FileOptions.WriteThrough)) - { - data.SaveTo(stream); - stream.Flush(flushToDisk: true); - } + await WriteEncodedDataAsync(data, temporaryPath, cancellationToken).ConfigureAwait(false); // Move the complete temporary file into place. If another process // takes the same friendly name between checks, choose the next name. for (var attempt = 0; attempt < 100; attempt++) { - var outputPath = GetUniqueOutputPath(fullBaseFolder, fileNoExt); + cancellationToken.ThrowIfCancellationRequested(); + var outputPath = GetUniqueOutputPath(fullBaseFolder, fileNoExt, cancellationToken); try { + cancellationToken.ThrowIfCancellationRequested(); File.Move(temporaryPath, outputPath); return outputPath; } @@ -72,8 +85,29 @@ public string ExportCutout(SKBitmap sourceBitmap, CutoutModel cutout, string bas } } - private static string GetUniqueOutputPath(string baseFolder, string fileNoExt) + private static async Task WriteEncodedDataAsync( + SKData data, + string temporaryPath, + CancellationToken cancellationToken) + { + await using var output = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 81920, + FileOptions.Asynchronous | FileOptions.WriteThrough); + using var encoded = data.AsStream(); + await encoded.CopyToAsync(output, 81920, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private static string GetUniqueOutputPath( + string baseFolder, + string fileNoExt, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var sanitized = SanitizeFileStem(fileNoExt); var candidate = Path.Combine(baseFolder, sanitized + ".png"); if (!File.Exists(candidate)) @@ -83,6 +117,7 @@ private static string GetUniqueOutputPath(string baseFolder, string fileNoExt) for (var index = 1; index < 10000; index++) { + cancellationToken.ThrowIfCancellationRequested(); candidate = Path.Combine(baseFolder, $"{sanitized}_{index:00}.png"); if (!File.Exists(candidate)) { diff --git a/solution/ImageUiSlicer/Services/ImageService.cs b/solution/ImageUiSlicer/Services/ImageService.cs index bf2fc0e..6d3b91b 100644 --- a/solution/ImageUiSlicer/Services/ImageService.cs +++ b/solution/ImageUiSlicer/Services/ImageService.cs @@ -2,27 +2,12 @@ namespace ImageUiSlicer.Services; -public sealed class ImageService -{ - private static readonly HashSet SupportedExtensions = - [ - ".png", - ".jpg", - ".jpeg", - ".bmp", - ".gif", - ".webp", - ]; - - public bool IsSupportedImage(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return false; - } +public readonly record struct SourceImageFingerprint(long FileSizeBytes, string ContentSha256); - return SupportedExtensions.Contains(Path.GetExtension(path).ToLowerInvariant()); - } +public sealed class ImageService : Contracts.IImageService +{ + public bool IsSupportedImage(string path) => + Core.Images.ImageFormatCatalog.SupportsFile(path); public SKBitmap LoadBitmap(string path) { @@ -54,6 +39,14 @@ public SKBitmap LoadBitmap(string path) return ApplyEncodedOrientation(bitmap, codec.EncodedOrigin); } + public SourceImageFingerprint ReadFingerprint(string path) + { + using var stream = File.OpenRead(path); + var fileSizeBytes = stream.Length; + var hash = System.Security.Cryptography.SHA256.HashData(stream); + return new SourceImageFingerprint(fileSizeBytes, Convert.ToHexString(hash).ToLowerInvariant()); + } + private static SKBitmap ApplyEncodedOrientation(SKBitmap source, SKEncodedOrigin origin) { if (origin is SKEncodedOrigin.TopLeft or SKEncodedOrigin.Default) diff --git a/solution/ImageUiSlicer/Services/ProjectLoadResult.cs b/solution/ImageUiSlicer/Services/ProjectLoadResult.cs new file mode 100644 index 0000000..5fb740e --- /dev/null +++ b/solution/ImageUiSlicer/Services/ProjectLoadResult.cs @@ -0,0 +1,10 @@ +using ImageUiSlicer.Models; + +namespace ImageUiSlicer.Services; + +public sealed record ProjectLoadResult( + ProjectModel Project, + IReadOnlyList Warnings) +{ + public bool WasRepaired => Warnings.Count > 0; +} diff --git a/solution/ImageUiSlicer/Services/ProjectService.cs b/solution/ImageUiSlicer/Services/ProjectService.cs index 37961db..df21cb1 100644 --- a/solution/ImageUiSlicer/Services/ProjectService.cs +++ b/solution/ImageUiSlicer/Services/ProjectService.cs @@ -6,8 +6,9 @@ namespace ImageUiSlicer.Services; -public sealed class ProjectService +public sealed class ProjectService : Contracts.IProjectService { + private const long MaxWorkspaceFileBytes = 64L * 1024 * 1024; private const int MaxLoadedGeometryPoints = 100_000; private const float MaxLoadedCoordinateMagnitude = 1_000_000f; @@ -22,27 +23,87 @@ public sealed class ProjectService public ProjectModel Load(string path) { + return LoadWithReport(path).Project; + } + + public ProjectLoadResult LoadWithReport(string path) + { + var fileInfo = new FileInfo(path); + if (fileInfo.Length > MaxWorkspaceFileBytes) + { + throw new InvalidDataException("This workspace is larger than 64 MB and cannot be opened safely."); + } + var json = File.ReadAllText(path); var project = JsonSerializer.Deserialize(json, SerializerOptions) ?? throw new InvalidOperationException("Project file was empty."); + var warnings = new List(); project.ProjectFilePath = path; project.Cutouts ??= new(); project.SavedShapes ??= new(); project.SourceImage ??= new(); project.Defaults ??= new(); + project.ExportSettings ??= new(); // JSON can contain explicit null collection entries despite the // non-nullable model declarations. Discard only those unusable entries; // all valid legacy cut-outs and saved shapes remain in their original order. + ValidateAggregateLimits(project, "opened"); + + var originalCutoutCount = project.Cutouts.Count; + var originalSavedShapeCount = project.SavedShapes.Count; project.Cutouts = new ObservableCollection(project.Cutouts.OfType()); project.SavedShapes = new ObservableCollection(project.SavedShapes.OfType()); + if (project.Cutouts.Count != originalCutoutCount) + { + warnings.Add("Removed unusable empty entries from the cut-out list."); + } + + if (project.SavedShapes.Count != originalSavedShapeCount) + { + warnings.Add("Removed unusable empty entries from the saved-shape list."); + } project.SourceImage.Path ??= string.Empty; project.SourceImage.PixelWidth = Math.Max(0, project.SourceImage.PixelWidth); project.SourceImage.PixelHeight = Math.Max(0, project.SourceImage.PixelHeight); + project.SourceImage.FileSizeBytes = Math.Max(0, project.SourceImage.FileSizeBytes); + var originalFingerprint = project.SourceImage.ContentSha256; + project.SourceImage.ContentSha256 = NormalizeSha256(originalFingerprint); + if (!string.IsNullOrWhiteSpace(originalFingerprint) && string.IsNullOrWhiteSpace(project.SourceImage.ContentSha256)) + { + warnings.Add("Removed an invalid source-image fingerprint."); + } + + var originalPreset = project.ExportSettings.Preset; + project.ExportSettings.Preset = ExportPresetCatalog.Get(originalPreset).Key; + if (!string.IsNullOrWhiteSpace(originalPreset) && + !string.Equals(originalPreset, project.ExportSettings.Preset, StringComparison.Ordinal)) + { + warnings.Add("Replaced an unknown export preset with Custom."); + } + + var originalNamingMode = project.ExportSettings.NamingMode; + project.ExportSettings.NamingMode = ExportOptionsPolicy.NormalizeNamingMode(originalNamingMode); + if (!string.Equals(originalNamingMode, project.ExportSettings.NamingMode, StringComparison.Ordinal)) + { + warnings.Add("Replaced an unknown export naming mode with Auto Numbered."); + } + + var originalPrefix = project.ExportSettings.Prefix ?? string.Empty; + project.ExportSettings.Prefix = ExportOptionsPolicy.NormalizeFilePrefix(originalPrefix); + if (!string.Equals(originalPrefix, project.ExportSettings.Prefix, StringComparison.Ordinal)) + { + warnings.Add($"Shortened the export prefix to {ExportOptionsPolicy.MaxFilePrefixLength} characters."); + } project.Defaults.Format = "png"; - project.Defaults.Padding = Math.Max(0, project.Defaults.Padding); + var originalDefaultPadding = project.Defaults.Padding; + project.Defaults.Padding = ExportOptionsPolicy.NormalizePadding(project.Defaults.Padding); + if (project.Defaults.Padding != originalDefaultPadding) + { + warnings.Add($"Limited the default export padding to {ExportOptionsPolicy.MaxPadding:N0} pixels."); + } project.Defaults.OutlineMode = ExportOptionsPolicy.NormalizeOutlineMode(project.Defaults.OutlineMode); project.Defaults.TargetPixelSize = ExportOptionsPolicy.NormalizeTargetPixelSize(project.Defaults.TargetPixelSize); project.Defaults.Scale = 1; @@ -50,7 +111,11 @@ public ProjectModel Load(string path) var cutoutIds = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var cutout in project.Cutouts) { - cutout.Id = EnsureUniqueId(cutout.Id, cutoutIds); + cutout.Id = EnsureUniqueId(cutout.Id, cutoutIds, out var cutoutIdReplaced); + if (cutoutIdReplaced) + { + warnings.Add($"Assigned a safe unique ID to cut-out '{cutout.Name}'."); + } cutout.Name = string.IsNullOrWhiteSpace(cutout.Name) ? "Cutout" : cutout.Name.Trim(); cutout.Notes = cutout.Notes?.Trim() ?? string.Empty; if (cutout.AutoConfidence is double confidence && @@ -59,10 +124,19 @@ public ProjectModel Load(string path) cutout.AutoConfidence = null; } - cutout.Geometry = NormalizeGeometry(cutout.Geometry, "freehand"); + cutout.Geometry = NormalizeGeometry(cutout.Geometry, "freehand", out var cutoutGeometryDiscarded); + if (cutoutGeometryDiscarded) + { + warnings.Add($"Removed unsafe geometry from cut-out '{cutout.Name}'."); + } cutout.Export ??= new ExportOptionsModel(); cutout.Export.Format = "png"; - cutout.Export.Padding = Math.Max(0, cutout.Export.Padding); + var originalPadding = cutout.Export.Padding; + cutout.Export.Padding = ExportOptionsPolicy.NormalizePadding(cutout.Export.Padding); + if (cutout.Export.Padding != originalPadding) + { + warnings.Add($"Limited export padding for cut-out '{cutout.Name}' to {ExportOptionsPolicy.MaxPadding:N0} pixels."); + } cutout.Export.OutlineMode = ExportOptionsPolicy.NormalizeOutlineMode(cutout.Export.OutlineMode); cutout.BBox = GeometryHelper.IsValidGeometry(cutout.Geometry) ? GeometryHelper.ComputeBBox(cutout.Geometry.Points) @@ -77,12 +151,26 @@ public ProjectModel Load(string path) var savedShapeIds = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var shape in project.SavedShapes) { - shape.Id = EnsureUniqueId(shape.Id, savedShapeIds); - shape.Geometry = NormalizeGeometry(shape.Geometry, "shape-custom"); + shape.Id = EnsureUniqueId(shape.Id, savedShapeIds, out var shapeIdReplaced); + if (shapeIdReplaced) + { + warnings.Add($"Assigned a safe unique ID to saved shape '{shape.Name}'."); + } + + shape.Geometry = NormalizeGeometry(shape.Geometry, "shape-custom", out var shapeGeometryDiscarded); + if (shapeGeometryDiscarded) + { + warnings.Add($"Removed unsafe geometry from saved shape '{shape.Name}'."); + } shape.Name = string.IsNullOrWhiteSpace(shape.Name) ? "Custom Shape" : shape.Name.Trim(); if (!GeometryHelper.IsValidGeometry(shape.Geometry)) { + if (!shapeGeometryDiscarded) + { + warnings.Add($"Removed invalid geometry from saved shape '{shape.Name}'."); + } + shape.Geometry = new PathGeometryModel(); shape.BBox = new BBox(0, 0, 0, 0); continue; @@ -99,16 +187,33 @@ public ProjectModel Load(string path) project.ProjectName = Path.GetFileNameWithoutExtension(path); } - return project; + return new ProjectLoadResult(project, warnings); } public void Save(ProjectModel project, string path) { + ArgumentNullException.ThrowIfNull(project); ArgumentException.ThrowIfNullOrWhiteSpace(path); + + // Validate before changing live project state. A rejected save must not + // leave the editor pointing at a path that was never written. + ValidateAggregateLimits(project, "saved"); + + var previousPath = project.ProjectFilePath; + var previousModifiedUtc = project.ModifiedUtc; project.ProjectFilePath = path; project.ModifiedUtc = DateTime.UtcNow; - var json = JsonSerializer.Serialize(project, SerializerOptions); - AtomicFile.WriteAllText(path, json); + try + { + var json = JsonSerializer.Serialize(project, SerializerOptions); + AtomicFile.WriteAllText(path, json); + } + catch + { + project.ProjectFilePath = previousPath; + project.ModifiedUtc = previousModifiedUtc; + throw; + } } private static int ResolveLegacyTargetPixelSize(CutoutModel cutout) @@ -122,8 +227,12 @@ private static int ResolveLegacyTargetPixelSize(CutoutModel cutout) return (int)Math.Clamp(longestSide * scale, 1L, int.MaxValue); } - private static PathGeometryModel NormalizeGeometry(PathGeometryModel? geometry, string fallbackMode) + private static PathGeometryModel NormalizeGeometry( + PathGeometryModel? geometry, + string fallbackMode, + out bool geometryDiscarded) { + geometryDiscarded = false; geometry ??= new PathGeometryModel(); geometry.Type = string.IsNullOrWhiteSpace(geometry.Type) ? "path" : geometry.Type.Trim(); geometry.Mode = string.IsNullOrWhiteSpace(geometry.Mode) ? fallbackMode : geometry.Mode.Trim(); @@ -141,6 +250,7 @@ private static PathGeometryModel NormalizeGeometry(PathGeometryModel? geometry, MathF.Abs(point.Y) > MaxLoadedCoordinateMagnitude)) { geometry.Points = new List(); + geometryDiscarded = true; return geometry; } @@ -148,14 +258,17 @@ private static PathGeometryModel NormalizeGeometry(PathGeometryModel? geometry, return geometry; } - private static string EnsureUniqueId(string? candidate, ISet usedIds) + private static string EnsureUniqueId(string? candidate, ISet usedIds, out bool replaced) { var id = candidate?.Trim() ?? string.Empty; if (id.Length > 0 && usedIds.Add(id)) { + replaced = false; return id; } + replaced = true; + do { id = Guid.NewGuid().ToString("N"); @@ -164,4 +277,36 @@ private static string EnsureUniqueId(string? candidate, ISet usedIds) return id; } + + private static void ValidateAggregateLimits(ProjectModel project, string operation) + { + if (project.Cutouts.Count > WorkspacePolicy.MaxCutouts) + { + throw new InvalidDataException($"This workspace contains more than {WorkspacePolicy.MaxCutouts:N0} cut-outs and cannot be {operation} safely."); + } + + if (project.SavedShapes.Count > WorkspacePolicy.MaxSavedShapes) + { + throw new InvalidDataException($"This workspace contains more than {WorkspacePolicy.MaxSavedShapes:N0} saved shapes and cannot be {operation} safely."); + } + + var totalPoints = project.Cutouts + .OfType() + .Sum(cutout => (long)(cutout.Geometry?.Points?.Count ?? 0)); + totalPoints += project.SavedShapes + .OfType() + .Sum(shape => (long)(shape.Geometry?.Points?.Count ?? 0)); + if (totalPoints > WorkspacePolicy.MaxGeometryPoints) + { + throw new InvalidDataException($"This workspace contains more than {WorkspacePolicy.MaxGeometryPoints:N0} geometry points and cannot be {operation} safely."); + } + } + + private static string NormalizeSha256(string? value) + { + var normalized = value?.Trim().ToLowerInvariant() ?? string.Empty; + return normalized.Length == 64 && normalized.All(Uri.IsHexDigit) + ? normalized + : string.Empty; + } } diff --git a/solution/ImageUiSlicer/Services/SettingsService.cs b/solution/ImageUiSlicer/Services/SettingsService.cs index 10cdb14..fcfdebd 100644 --- a/solution/ImageUiSlicer/Services/SettingsService.cs +++ b/solution/ImageUiSlicer/Services/SettingsService.cs @@ -4,7 +4,7 @@ namespace ImageUiSlicer.Services; -public sealed class SettingsService +public sealed class SettingsService : Contracts.ISettingsService { private static readonly JsonSerializerOptions SerializerOptions = new() { diff --git a/solution/ImageUiSlicer/Services/WorkspacePolicy.cs b/solution/ImageUiSlicer/Services/WorkspacePolicy.cs new file mode 100644 index 0000000..818678a --- /dev/null +++ b/solution/ImageUiSlicer/Services/WorkspacePolicy.cs @@ -0,0 +1,14 @@ +namespace ImageUiSlicer.Services; + +/// +/// Defines safety limits that must stay consistent across workspace editing, +/// saving, and loading. +/// +public static class WorkspacePolicy +{ + public const int MaxCutouts = 500; + public const int MaxSavedShapes = 10_000; + public const long MaxGeometryPoints = 1_000_000; + + public static bool CanAddCutout(int currentCount) => currentCount < MaxCutouts; +} diff --git a/solution/ImageUiSlicer/ViewModels/MainViewModel.CommandState.cs b/solution/ImageUiSlicer/ViewModels/MainViewModel.CommandState.cs index 9f89ea5..64ffc84 100644 --- a/solution/ImageUiSlicer/ViewModels/MainViewModel.CommandState.cs +++ b/solution/ImageUiSlicer/ViewModels/MainViewModel.CommandState.cs @@ -6,6 +6,7 @@ private void RefreshCommands() { ExportAllCommand.RaiseCanExecuteChanged(); ExportSelectedCommand.RaiseCanExecuteChanged(); + CancelOperationCommand.RaiseCanExecuteChanged(); CommitSelectionCommand.RaiseCanExecuteChanged(); ClearSelectionCommand.RaiseCanExecuteChanged(); DeleteSelectedCommand.RaiseCanExecuteChanged(); @@ -30,6 +31,7 @@ private void UpdateComputedState() RaisePropertyChanged(nameof(HasSelectedSavedShape)); RaisePropertyChanged(nameof(HasSavedShapes)); RaisePropertyChanged(nameof(SavedShapesHeader)); + RaisePropertyChanged(nameof(CanCommitOrFinalizeSelection)); RefreshSelectedCutoutInspectorPreview(); RefreshCommands(); } diff --git a/solution/ImageUiSlicer/ViewModels/MainViewModel.CutoutWorkflow.cs b/solution/ImageUiSlicer/ViewModels/MainViewModel.CutoutWorkflow.cs index 62918fd..5c1666a 100644 --- a/solution/ImageUiSlicer/ViewModels/MainViewModel.CutoutWorkflow.cs +++ b/solution/ImageUiSlicer/ViewModels/MainViewModel.CutoutWorkflow.cs @@ -1,5 +1,6 @@ using ImageUiSlicer.Core.Geometry; using ImageUiSlicer.Models; +using ImageUiSlicer.Services; namespace ImageUiSlicer.ViewModels; @@ -171,6 +172,13 @@ private void CommitSelection() return; } + if (!WorkspacePolicy.CanAddCutout(Project.Cutouts.Count)) + { + StatusText = $"This workspace already has {WorkspacePolicy.MaxCutouts:N0} cut-outs. Delete one before saving another."; + RefreshCommands(); + return; + } + if (!GeometryHelper.IsValidGeometry(ActiveSelection.Geometry)) { ClearActiveSelection(recordHistory: false); @@ -178,6 +186,15 @@ private void CommitSelection() return; } + if (SourceBitmap is null || !GeometryHelper.IntersectsImage( + ActiveSelection.Geometry, + SourceBitmap.Width, + SourceBitmap.Height)) + { + StatusText = "That outline is outside the image. Move it over the image before saving."; + return; + } + var committedGeometry = ActiveSelection.Geometry.DeepClone(); PushUndoSnapshot(); var cutout = new CutoutModel diff --git a/solution/ImageUiSlicer/ViewModels/MainViewModel.Export.cs b/solution/ImageUiSlicer/ViewModels/MainViewModel.Export.cs index c110f28..81aa076 100644 --- a/solution/ImageUiSlicer/ViewModels/MainViewModel.Export.cs +++ b/solution/ImageUiSlicer/ViewModels/MainViewModel.Export.cs @@ -6,36 +6,58 @@ namespace ImageUiSlicer.ViewModels; public sealed partial class MainViewModel { - private readonly ExportBatchCoordinator _exportBatchCoordinator = new(); + private readonly IExportBatchCoordinator _exportBatchCoordinator; - public RelayCommand ExportAllCommand { get; } + public AsyncRelayCommand ExportAllCommand { get; } - public RelayCommand ExportSelectedCommand { get; } + public AsyncRelayCommand ExportSelectedCommand { get; } - private void ExportAll() - { - ExportCutouts(selectedCutouts: null); - } - - private void ExportSelected() - { - ExportCutouts(SelectedCutouts); - } - - private void ExportCutouts(IReadOnlyCollection? selectedCutouts) + private async Task ExportCutoutsAsync( + IReadOnlyCollection? selectedCutouts, + CancellationToken cancellationToken) { if (SourceBitmap is null) { return; } - var result = _exportBatchCoordinator.Export( - SourceBitmap, - Project.Cutouts, - selectedCutouts, - ExportFolder, - ExportNamingMode, - ExportPrefix); - StatusText = result.Summary; + if (!TryBeginOperation("Preparing PNG export…", cancellationToken, out var operationToken)) + { + return; + } + + var orderedCutouts = Project.Cutouts.Select(cutout => cutout.DeepClone()).ToList(); + var selectedIds = selectedCutouts?.Select(cutout => cutout.Id).ToHashSet(StringComparer.Ordinal); + var selectedClones = selectedIds is null + ? null + : orderedCutouts.Where(cutout => selectedIds.Contains(cutout.Id)).ToList(); + var sourceBitmap = SourceBitmap; + var progress = new Progress(value => UpdateOperationStatus(value.Message)); + + try + { + var result = await _exportBatchCoordinator.ExportAsync( + sourceBitmap, + orderedCutouts, + selectedClones, + ExportFolder, + ExportNamingMode, + ExportPrefix, + progress, + operationToken); + StatusText = result.Summary; + } + catch (OperationCanceledException) + { + StatusText = "Export cancelled. Any PNG files already completed were kept."; + } + catch (Exception ex) + { + StatusText = $"Export failed: {ex.Message}"; + } + finally + { + CompleteOperation(); + } } } diff --git a/solution/ImageUiSlicer/ViewModels/MainViewModel.ExportPresets.cs b/solution/ImageUiSlicer/ViewModels/MainViewModel.ExportPresets.cs index a9b6db3..0e40930 100644 --- a/solution/ImageUiSlicer/ViewModels/MainViewModel.ExportPresets.cs +++ b/solution/ImageUiSlicer/ViewModels/MainViewModel.ExportPresets.cs @@ -37,6 +37,7 @@ public string ExportPreset _settings.LastExportPreset = normalized; ApplyExportPreset(normalized, markDirty: false, applyToExistingCutouts: true); + SyncProjectExportSettings(); PersistSettings(); if (HasImage) { @@ -76,6 +77,7 @@ private void ApplyExportPreset(string preset, bool markDirty, bool applyToExisti _exportPrefix = definition.FilePrefix; RaisePropertyChanged(nameof(ExportPrefix)); _settings.LastExportPrefix = definition.FilePrefix; + SyncProjectExportSettings(); Project.Defaults.Scale = defaults.Scale; Project.Defaults.TargetPixelSize = defaults.TargetPixelSize; @@ -119,9 +121,35 @@ private void SetExportPresetToCustom() _exportPreset = "custom"; _settings.LastExportPreset = _exportPreset; + SyncProjectExportSettings(); RaisePropertyChanged(nameof(ExportPreset)); } + private void ApplyProjectExportSettings() + { + Project.ExportSettings ??= new ProjectExportSettingsModel(); + _exportPreset = NormalizeExportPreset(Project.ExportSettings.Preset); + _exportNamingMode = ExportOptionsPolicy.NormalizeNamingMode(Project.ExportSettings.NamingMode); + _exportPrefix = ExportOptionsPolicy.NormalizeFilePrefix(Project.ExportSettings.Prefix); + SyncProjectExportSettings(); + + _settings.LastExportPreset = _exportPreset; + _settings.LastExportNamingMode = _exportNamingMode; + _settings.LastExportPrefix = _exportPrefix; + + RaisePropertyChanged(nameof(ExportPreset)); + RaisePropertyChanged(nameof(ExportNamingMode)); + RaisePropertyChanged(nameof(ExportPrefix)); + } + + private void SyncProjectExportSettings() + { + Project.ExportSettings ??= new ProjectExportSettingsModel(); + Project.ExportSettings.Preset = _exportPreset; + Project.ExportSettings.NamingMode = _exportNamingMode; + Project.ExportSettings.Prefix = _exportPrefix; + } + private void ReconcileExportPresetWithProjectDefaults() { var definition = ExportPresetCatalog.Get(_exportPreset); diff --git a/solution/ImageUiSlicer/ViewModels/MainViewModel.ExportSettings.cs b/solution/ImageUiSlicer/ViewModels/MainViewModel.ExportSettings.cs index 03b62dc..8924edf 100644 --- a/solution/ImageUiSlicer/ViewModels/MainViewModel.ExportSettings.cs +++ b/solution/ImageUiSlicer/ViewModels/MainViewModel.ExportSettings.cs @@ -30,7 +30,8 @@ public string ExportNamingMode get => _exportNamingMode; set { - if (string.Equals(_exportNamingMode, value, StringComparison.Ordinal)) + var normalized = ExportOptionsPolicy.NormalizeNamingMode(value); + if (string.Equals(_exportNamingMode, normalized, StringComparison.Ordinal)) { return; } @@ -40,10 +41,11 @@ public string ExportNamingMode PushUndoSnapshot(); } - _exportNamingMode = value; + _exportNamingMode = normalized; RaisePropertyChanged(); SetExportPresetToCustom(); - _settings.LastExportNamingMode = value; + SyncProjectExportSettings(); + _settings.LastExportNamingMode = normalized; PersistSettings(); if (HasImage) { @@ -57,7 +59,8 @@ public string ExportPrefix get => _exportPrefix; set { - if (string.Equals(_exportPrefix, value, StringComparison.Ordinal)) + var normalized = ExportOptionsPolicy.NormalizeFilePrefix(value); + if (string.Equals(_exportPrefix, normalized, StringComparison.Ordinal)) { return; } @@ -67,10 +70,11 @@ public string ExportPrefix PushUndoSnapshot(); } - _exportPrefix = value; + _exportPrefix = normalized; RaisePropertyChanged(); SetExportPresetToCustom(); - _settings.LastExportPrefix = value; + SyncProjectExportSettings(); + _settings.LastExportPrefix = normalized; PersistSettings(); if (HasImage) { diff --git a/solution/ImageUiSlicer/ViewModels/MainViewModel.History.cs b/solution/ImageUiSlicer/ViewModels/MainViewModel.History.cs index 0f751f0..83549d0 100644 --- a/solution/ImageUiSlicer/ViewModels/MainViewModel.History.cs +++ b/solution/ImageUiSlicer/ViewModels/MainViewModel.History.cs @@ -6,6 +6,7 @@ namespace ImageUiSlicer.ViewModels; public sealed partial class MainViewModel { private const int MaxHistoryEntries = 80; + private const long MaxHistoryGeometryPoints = 500_000; private readonly Stack _undoStack = new(); private readonly Stack _redoStack = new(); @@ -86,6 +87,7 @@ private void RestoreSnapshot(EditorSnapshot snapshot) _settings.LastExportPreset = _exportPreset; _settings.LastExportNamingMode = _exportNamingMode; _settings.LastExportPrefix = _exportPrefix; + SyncProjectExportSettings(); SelectedSavedShape = snapshot.SelectedSavedShapeId is null ? Project.SavedShapes.FirstOrDefault() @@ -135,20 +137,32 @@ snapshot.ActiveSelectionGeometry is not null && private static void PushHistorySnapshot(Stack stack, EditorSnapshot snapshot) { - if (stack.Count >= MaxHistoryEntries) + stack.Push(snapshot); + + var retained = new List(); + long retainedGeometryPoints = 0; + foreach (var entry in stack) { - var recentEntries = stack - .Take(MaxHistoryEntries - 1) - .Reverse() - .ToList(); - stack.Clear(); - foreach (var entry in recentEntries) + if (retained.Count >= MaxHistoryEntries) + { + break; + } + + var entryPoints = Math.Max(0, entry.GeometryPointCount); + if (retained.Count > 0 && retainedGeometryPoints + entryPoints > MaxHistoryGeometryPoints) { - stack.Push(entry); + break; } + + retained.Add(entry); + retainedGeometryPoints += entryPoints; } - stack.Push(snapshot); + stack.Clear(); + foreach (var entry in retained.AsEnumerable().Reverse()) + { + stack.Push(entry); + } } private sealed record EditorSnapshot( @@ -164,5 +178,11 @@ private sealed record EditorSnapshot( PathGeometryModel? ActiveSelectionGeometry, BBox ActiveSelectionBBox, bool HasActiveSelection, - bool WasDirty); + bool WasDirty) + { + public long GeometryPointCount => + Cutouts.Sum(cutout => (long)cutout.Geometry.Points.Count) + + SavedShapes.Sum(shape => (long)shape.Geometry.Points.Count) + + (ActiveSelectionGeometry?.Points.Count ?? 0); + } } diff --git a/solution/ImageUiSlicer/ViewModels/MainViewModel.Layout.cs b/solution/ImageUiSlicer/ViewModels/MainViewModel.Layout.cs index ce04f54..a2b36fd 100644 --- a/solution/ImageUiSlicer/ViewModels/MainViewModel.Layout.cs +++ b/solution/ImageUiSlicer/ViewModels/MainViewModel.Layout.cs @@ -1,3 +1,6 @@ +using ImageUiSlicer.CanvasEngine; +using ImageUiSlicer.Models; + namespace ImageUiSlicer.ViewModels; public sealed partial class MainViewModel @@ -17,9 +20,43 @@ public sealed partial class MainViewModel private string _cutLineColor = "Orange"; private bool _isLightMode; private bool _smoothZoomPreview = true; + private FreehandResponse _freehandResponse = FreehandResponse.Balanced; + private FreehandSmoothing _freehandSmoothing = FreehandSmoothing.Medium; - public IReadOnlyList PrecisionMagnifierZoomLevels { get; } = [5, 6, 7, 8, 10]; + public IReadOnlyList PrecisionMagnifierZoomLevels { get; } = ViewportOptions.MagnifierZoomLevels; public IReadOnlyList CutLineColorOptions { get; } = ["Orange", "Cyan", "White", "Black", "Lime", "Magenta"]; + public IReadOnlyList FreehandResponseOptions { get; } = Enum.GetValues(); + public IReadOnlyList FreehandSmoothingOptions { get; } = Enum.GetValues(); + + public FreehandResponse FreehandResponse + { + get => _freehandResponse; + set + { + if (!SetProperty(ref _freehandResponse, value)) + { + return; + } + + _settings.LastFreehandResponse = value.ToString(); + PersistSettings(); + } + } + + public FreehandSmoothing FreehandSmoothing + { + get => _freehandSmoothing; + set + { + if (!SetProperty(ref _freehandSmoothing, value)) + { + return; + } + + _settings.LastFreehandSmoothing = value.ToString(); + PersistSettings(); + } + } public double LeftPaneWidth { @@ -143,6 +180,12 @@ private void InitializeLayoutPreferences() : "Orange"; _isLightMode = _settings.LastLightMode; _smoothZoomPreview = _settings.LastSmoothZoomPreview; + _freehandResponse = Enum.TryParse(_settings.LastFreehandResponse, true, out var response) + ? response + : FreehandResponse.Balanced; + _freehandSmoothing = Enum.TryParse(_settings.LastFreehandSmoothing, true, out var smoothing) + ? smoothing + : FreehandSmoothing.Medium; } public void SetPreviewFocusMode(bool isEnabled) diff --git a/solution/ImageUiSlicer/ViewModels/MainViewModel.OperationState.cs b/solution/ImageUiSlicer/ViewModels/MainViewModel.OperationState.cs new file mode 100644 index 0000000..b56dbd4 --- /dev/null +++ b/solution/ImageUiSlicer/ViewModels/MainViewModel.OperationState.cs @@ -0,0 +1,88 @@ +using ImageUiSlicer.Infrastructure; + +namespace ImageUiSlicer.ViewModels; + +public sealed partial class MainViewModel +{ + private CancellationTokenSource? _activeOperationCancellation; + private bool _isBusy; + private string _busyMessage = string.Empty; + + public bool IsBusy + { + get => _isBusy; + private set + { + if (SetProperty(ref _isBusy, value)) + { + RaisePropertyChanged(nameof(IsNotBusy)); + RefreshCommands(); + } + } + } + + public bool IsNotBusy => !IsBusy; + + public string BusyMessage + { + get => _busyMessage; + private set => SetProperty(ref _busyMessage, value); + } + + public RelayCommand CancelOperationCommand { get; private set; } = null!; + + public bool TryBeginOperation( + string message, + CancellationToken cancellationToken, + out CancellationToken operationToken) + { + if (IsBusy) + { + operationToken = cancellationToken; + return false; + } + + _activeOperationCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + BusyMessage = message; + StatusText = message; + IsBusy = true; + CancelOperationCommand.RaiseCanExecuteChanged(); + operationToken = _activeOperationCancellation.Token; + return true; + } + + public void UpdateOperationStatus(string message) + { + if (!IsBusy) + { + return; + } + + BusyMessage = message; + StatusText = message; + } + + public void CompleteOperation() + { + var cancellation = _activeOperationCancellation; + _activeOperationCancellation = null; + cancellation?.Dispose(); + IsBusy = false; + BusyMessage = string.Empty; + CancelOperationCommand.RaiseCanExecuteChanged(); + } + + public void CancelCurrentOperation() + { + if (!IsBusy) + { + return; + } + + BusyMessage = "Cancelling…"; + StatusText = BusyMessage; + _activeOperationCancellation?.Cancel(); + ExportAllCommand.Cancel(); + ExportSelectedCommand.Cancel(); + } +} diff --git a/solution/ImageUiSlicer/ViewModels/MainViewModel.Preview.cs b/solution/ImageUiSlicer/ViewModels/MainViewModel.Preview.cs index bc0c8d1..ce9f2db 100644 --- a/solution/ImageUiSlicer/ViewModels/MainViewModel.Preview.cs +++ b/solution/ImageUiSlicer/ViewModels/MainViewModel.Preview.cs @@ -7,7 +7,7 @@ namespace ImageUiSlicer.ViewModels; public sealed partial class MainViewModel { - private readonly CutoutPreviewFactory _cutoutPreviewFactory = new(); + private readonly ICutoutPreviewFactory _cutoutPreviewFactory; private bool _splitPreviewEnabled = false; private double _splitPreviewRatio = 0.5; diff --git a/solution/ImageUiSlicer/ViewModels/MainViewModel.ProjectLifecycle.cs b/solution/ImageUiSlicer/ViewModels/MainViewModel.ProjectLifecycle.cs index c17e237..5a4f53c 100644 --- a/solution/ImageUiSlicer/ViewModels/MainViewModel.ProjectLifecycle.cs +++ b/solution/ImageUiSlicer/ViewModels/MainViewModel.ProjectLifecycle.cs @@ -1,15 +1,16 @@ using System.Collections.ObjectModel; using ImageUiSlicer.Models; using ImageUiSlicer.Services; +using ImageUiSlicer.Services.Contracts; using SkiaSharp; namespace ImageUiSlicer.ViewModels; public sealed partial class MainViewModel { - private readonly SettingsService _settingsService = new(); - private readonly ProjectService _projectService = new(); - private readonly ImageService _imageService = new(); + private readonly ISettingsService _settingsService; + private readonly IProjectService _projectService; + private readonly IImageService _imageService; private readonly AppSettings _settings; private SKBitmap? _sourceBitmap; @@ -71,9 +72,14 @@ public string GetInitialImageFolder() public SKBitmap LoadBitmap(string path) => _imageService.LoadBitmap(path); - public ProjectModel LoadProjectMetadata(string path) => _projectService.Load(path); + public SourceImageFingerprint ReadSourceImageFingerprint(string path) => _imageService.ReadFingerprint(path); - public void ApplyImageProject(string imagePath, SKBitmap bitmap) + public ProjectLoadResult LoadProjectMetadata(string path) => _projectService.LoadWithReport(path); + + public void ApplyImageProject( + string imagePath, + SKBitmap bitmap, + SourceImageFingerprint? fingerprint = null) { var reusableShapes = new ObservableCollection( Project.SavedShapes.Select(shape => shape.DeepClone())); @@ -85,8 +91,16 @@ public void ApplyImageProject(string imagePath, SKBitmap bitmap) Path = imagePath, PixelWidth = bitmap.Width, PixelHeight = bitmap.Height, + FileSizeBytes = fingerprint?.FileSizeBytes ?? 0, + ContentSha256 = fingerprint?.ContentSha256 ?? string.Empty, }, Defaults = CreateDefaultsForNewImage(), + ExportSettings = new ProjectExportSettingsModel + { + Preset = ExportPreset, + NamingMode = ExportNamingMode, + Prefix = ExportPrefix, + }, SavedShapes = reusableShapes, ShowCutoutsOverlay = true, }; @@ -107,6 +121,7 @@ public void ApplyLoadedProject(ProjectModel project, SKBitmap bitmap, string pro public void SaveProject(string filePath) { + SyncProjectExportSettings(); _projectService.Save(Project, filePath); RememberProjectFolder(filePath); IsDirty = false; @@ -134,6 +149,7 @@ private void ApplyProject(ProjectModel project, SKBitmap bitmap, string imagePat Project.ProjectName = string.IsNullOrWhiteSpace(Project.ProjectName) ? Path.GetFileNameWithoutExtension(imagePath) : Project.ProjectName; + ApplyProjectExportSettings(); ReconcileExportPresetWithProjectDefaults(); ClearActiveSelection(recordHistory: false); @@ -175,6 +191,7 @@ private void ApplyProject(ProjectModel project, SKBitmap bitmap, string imagePat RaisePropertyChanged(nameof(SourceImageFullPathLabel)); RaisePropertyChanged(nameof(WindowTitle)); RefreshSelectedCutoutInspectorPreview(); + PersistSettings(); RefreshCommands(); } diff --git a/solution/ImageUiSlicer/ViewModels/MainViewModel.cs b/solution/ImageUiSlicer/ViewModels/MainViewModel.cs index 2ed4a00..d41a4fa 100644 --- a/solution/ImageUiSlicer/ViewModels/MainViewModel.cs +++ b/solution/ImageUiSlicer/ViewModels/MainViewModel.cs @@ -2,6 +2,10 @@ using ImageUiSlicer.CanvasEngine.Contracts; using ImageUiSlicer.Infrastructure; using ImageUiSlicer.Models; +using ImageUiSlicer.Presentation.Preview; +using ImageUiSlicer.Services; +using ImageUiSlicer.Services.Contracts; +using ImageUiSlicer.Workflows.Exporting; using SkiaSharp; namespace ImageUiSlicer.ViewModels; @@ -15,7 +19,27 @@ public sealed partial class MainViewModel : ObservableObject, ICanvasEditorConte private bool _suspendDirtyTracking; public MainViewModel() + : this( + new SettingsService(), + new ProjectService(), + new ImageService(), + new ExportBatchCoordinator(), + new CutoutPreviewFactory()) { + } + + public MainViewModel( + ISettingsService settingsService, + IProjectService? projectService = null, + IImageService? imageService = null, + IExportBatchCoordinator? exportBatchCoordinator = null, + ICutoutPreviewFactory? cutoutPreviewFactory = null) + { + _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + _projectService = projectService ?? new ProjectService(); + _imageService = imageService ?? new ImageService(); + _exportBatchCoordinator = exportBatchCoordinator ?? new ExportBatchCoordinator(); + _cutoutPreviewFactory = cutoutPreviewFactory ?? new CutoutPreviewFactory(); _settings = _settingsService.Load(); InitializeLayoutPreferences(); _exportFolder = _settings.DefaultExportFolder; @@ -25,9 +49,14 @@ public MainViewModel() SelectedCutouts = new ObservableCollection(); AttachProject(Project); - ExportAllCommand = new RelayCommand(ExportAll, () => HasImage && Project.Cutouts.Count > 0 && !string.IsNullOrWhiteSpace(ExportFolder)); - ExportSelectedCommand = new RelayCommand(ExportSelected, () => HasImage && SelectedCutouts.Count > 0 && !string.IsNullOrWhiteSpace(ExportFolder)); - CommitSelectionCommand = new RelayCommand(CommitSelection, () => HasActiveSelection && HasImage); + ExportAllCommand = new AsyncRelayCommand( + cancellationToken => ExportCutoutsAsync(selectedCutouts: null, cancellationToken), + () => IsNotBusy && HasImage && Project.Cutouts.Count > 0 && !string.IsNullOrWhiteSpace(ExportFolder)); + ExportSelectedCommand = new AsyncRelayCommand( + cancellationToken => ExportCutoutsAsync(SelectedCutouts, cancellationToken), + () => IsNotBusy && HasImage && SelectedCutouts.Count > 0 && !string.IsNullOrWhiteSpace(ExportFolder)); + CancelOperationCommand = new RelayCommand(CancelCurrentOperation, () => IsBusy); + CommitSelectionCommand = new RelayCommand(CommitSelection, () => CanCommitOrFinalizeSelection); ClearSelectionCommand = new RelayCommand(() => ClearActiveSelection(), () => HasActiveSelection); DeleteSelectedCommand = new RelayCommand(DeleteSelectedCutouts, () => SelectedCutouts.Count > 0); MoveUpCommand = new RelayCommand(MoveUp, CanMoveUp); @@ -39,6 +68,7 @@ public MainViewModel() InitializeCutoutNaming(); InitializeSavedShapeLibrary(); ApplyExportPreset(ExportPreset, markDirty: false, applyToExistingCutouts: false); + SyncProjectExportSettings(); RefreshSelectedCutoutInspectorPreview(); UpdateComputedState(); @@ -107,7 +137,10 @@ private set public bool HasActiveSelection => ActiveSelection.HasSelection; - public bool CanCommitOrFinalizeSelection => HasImage && HasActiveSelection; + public bool CanCommitOrFinalizeSelection => + HasImage && + HasActiveSelection && + WorkspacePolicy.CanAddCutout(Project.Cutouts.Count); public bool HasSelectedCutout => PrimarySelectedCutout is not null; diff --git a/solution/ImageUiSlicer/Views/Components/EditorHeader.xaml b/solution/ImageUiSlicer/Views/Components/EditorHeader.xaml index e87ea86..a825c3a 100644 --- a/solution/ImageUiSlicer/Views/Components/EditorHeader.xaml +++ b/solution/ImageUiSlicer/Views/Components/EditorHeader.xaml @@ -210,12 +210,8 @@