Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@
"Bash(find:*)",
"Bash(ls:*)",
"Bash(/mnt/c/Program\\ Files/dotnet/dotnet build)",
"Bash(\"/mnt/c/Program Files/dotnet/dotnet\" build)"
"Bash(\"/mnt/c/Program Files/dotnet/dotnet\" build)",
"PowerShell(git diff *)",
"PowerShell(dotnet build *)",
"Bash(gh issue list *)"
],
"deny": []
}
}
}
68 changes: 68 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Magick Crop & Measure is a WPF desktop app (Windows 10+) that corrects perspective distortion in photos and provides measurement tools. It uses ImageMagick for image processing, Emgu.CV (OpenCV wrapper) for shape detection, and WPF-UI (Fluent Design) for the UI.

## Build Commands

```bash
# Build the solution (from repo root)
dotnet build MagickCrop.sln

# Build a specific platform (arm64, x64, x86)
dotnet build MagickCrop/MagickCrop.csproj -p:Platform=x64

# Run (requires Windows with .NET 10)
dotnet run --project MagickCrop/MagickCrop.csproj

# Run tests
dotnet test MagickCrop.Tests/

# Publish self-contained for x64
dotnet publish MagickCrop/MagickCrop.csproj -r win-x64 --self-contained -o bld/x64/MagickCrop-Self-Contained
```

The MSIX packaging project (`MagickCrop-Package/MagickCrop-Package.wapproj`) is used for Store submissions and requires Visual Studio.

## Architecture

### Key Namespaces

- `MagickCrop` — root namespace; `MainWindow.xaml.cs` and `MainWindow.QuadrilateralHover.cs` are partial classes for the main window
- `MagickCrop.ViewModels` — `MainWindowViewModel` (CommunityToolkit.Mvvm `ObservableObject`) + `IMainWindowView` interface
- `MagickCrop.Helpers` — stateless static helpers for image processing operations
- `MagickCrop.Controls` — WPF `UserControl` subclasses for measurement overlays and interactive controls
- `MagickCrop.Models` — DTOs, `UndoRedo` stack, `AspectRatio`, `DraggingMode` enum
- `MagickCrop.Models.MeasurementControls` — serializable DTOs for each measurement type
- `MagickCrop.Services` — `RecentProjectsManager` (JSON-backed project persistence in `%LocalAppData%\MagickCrop`)
- `MagickCrop.Behaviors` — `PinchZoomBehavior` attached property

### View / ViewModel Split

`MainWindow` implements `IMainWindowView`, which exposes just the properties the ViewModel needs (image source, busy state, local-adjustment region). `MainWindowViewModel` holds all commands (CommunityToolkit source-generated `[RelayCommand]`) and observable state. UI-only logic (dragging handles, polygon rendering) stays in the code-behind.

### Image Processing Pipeline

All image mutations write to a **temp file** on disk and reload from it. Undo/redo (`UndoRedo` + `MagickImageUndoRedoItem`) stores before/after file paths and reloads from disk on undo/redo — there is no in-memory image stack.

Key helpers:
- `QuadrilateralDetector` — Emgu.CV-based contour detection, returns `DetectedQuadrilateral` with confidence score
- `GridStraightenHelper` — polynomial distortion via ImageMagick for grid-based warp correction
- `UnWarpCorrector` — barrel/pincushion correction using transfinite interpolation + polynomial distortion
- `EdgeCorrectionHelper` — wavy-edge straightening using the same transfinite interpolation approach
- `ImageExtensions` / `MagickExtensions` — extension methods bridging WPF types and Magick.NET types

### Measurement Controls

Each measurement type (Distance, Angle, Rectangle, Polygon, Circle, VerticalLine, HorizontalLine) is a `UserControl` with a corresponding `*Dto` model for JSON serialization. Controls live on a WPF `Canvas` overlaid on the image.

### Project Persistence

`RecentProjectsManager` saves projects as JSON files (including measurement state as `MagickCropMeasurementPackage`) with thumbnails under `%LocalAppData%\MagickCrop\Projects`. An auto-save timer fires every 5 seconds when a project is open.

### UI Framework

Uses **WPF-UI** (`Wpf.Ui`) for `FluentWindow`, theming, and Fluent controls. `MainWindow` extends `FluentWindow`. Theme follows system (light/dark) via `ApplicationThemeManager`.
2 changes: 1 addition & 1 deletion MagickCrop-Package/MagickCrop-Package.wapproj
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@
<None Include="Package.StoreAssociation.xml" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7705" PrivateAssets="all" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2270" PrivateAssets="all" />
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
</ItemGroup>
<ItemGroup>
Expand Down
4 changes: 2 additions & 2 deletions MagickCrop-Package/Package.appxmanifest
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>

<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
Expand All @@ -10,7 +10,7 @@
<Identity
Name="40087JoeFinApps.MagickCrop"
Publisher="CN=153F3B0F-BA3D-4964-8098-71AC78A1DF6A"
Version="1.11.1.0" />
Version="1.12.0.0" />

<Properties>
<DisplayName>MagickCrop</DisplayName>
Expand Down
75 changes: 75 additions & 0 deletions MagickCrop/Controls/MarkupShapeControl.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<UserControl
x:Class="MagickCrop.Controls.MarkupShapeControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Canvas x:Name="MeasurementCanvas">
<Canvas.ContextMenu>
<ContextMenu>
<MenuItem Click="RemoveMenuItem_Click" Header="Remove" />
</ContextMenu>
</Canvas.ContextMenu>

<!-- Shared line element for Line and Arrow tools -->
<Line
x:Name="ShapeLine"
Opacity="0.9"
Stroke="Red"
StrokeEndLineCap="Round"
StrokeStartLineCap="Round"
StrokeThickness="3" />

<!-- Arrowhead polygon (Arrow tool only) -->
<Polygon
x:Name="ArrowHead"
Fill="Red"
Opacity="0.9"
Visibility="Collapsed" />

<!-- Rectangle outline -->
<Rectangle
x:Name="ShapeRectangle"
Fill="Transparent"
Opacity="0.9"
Stroke="Red"
StrokeThickness="3"
Visibility="Collapsed" />

<!-- Ellipse outline -->
<Ellipse
x:Name="ShapeEllipse"
Fill="Transparent"
Opacity="0.9"
Stroke="Red"
StrokeThickness="3"
Visibility="Collapsed" />

<!-- Drag handles -->
<Ellipse
x:Name="Point1Handle"
Width="12"
Height="12"
Cursor="SizeAll"
Fill="Red"
MouseDown="HandlePoint_MouseDown"
Opacity="0.8"
Stroke="White"
StrokeThickness="1"
Tag="0"
ToolTip="Drag to move start point" />
<Ellipse
x:Name="Point2Handle"
Width="12"
Height="12"
Cursor="SizeAll"
Fill="Red"
MouseDown="HandlePoint_MouseDown"
Opacity="0.8"
Stroke="White"
StrokeThickness="1"
Tag="1"
ToolTip="Drag to move end point" />
</Canvas>
</UserControl>
215 changes: 215 additions & 0 deletions MagickCrop/Controls/MarkupShapeControl.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
using MagickCrop.Models;
using MagickCrop.Models.MeasurementControls;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;

namespace MagickCrop.Controls;

public partial class MarkupShapeControl : UserControl
{
private Point point1 = new(100, 100);
private Point point2 = new(300, 300);
private int pointDraggingIndex = -1;

public event MouseButtonEventHandler? MeasurementPointMouseDown;

public delegate void RemoveControlRequestedEventHandler(object sender, EventArgs e);
public event RemoveControlRequestedEventHandler? RemoveControlRequested;

private MarkupShapeType shapeType = MarkupShapeType.Rectangle;
public MarkupShapeType ShapeType
{
get => shapeType;
set
{
shapeType = value;
UpdateShapeVisibility();
UpdatePositions();
}
}

private Color strokeColor = Colors.Red;
public Color StrokeColor
{
get => strokeColor;
set
{
strokeColor = value;
UpdateColors();
}
}

private double strokeThickness = 3.0;
public double StrokeThickness
{
get => strokeThickness;
set
{
strokeThickness = value;
UpdateThickness();
UpdatePositions();
}
}

public MarkupShapeControl()
{
InitializeComponent();
UpdateShapeVisibility();
UpdatePositions();
UpdateColors();
}

private void UpdateShapeVisibility()
{
ShapeLine.Visibility = shapeType is MarkupShapeType.Line or MarkupShapeType.Arrow
? Visibility.Visible : Visibility.Collapsed;
ArrowHead.Visibility = shapeType == MarkupShapeType.Arrow
? Visibility.Visible : Visibility.Collapsed;
ShapeRectangle.Visibility = shapeType == MarkupShapeType.Rectangle
? Visibility.Visible : Visibility.Collapsed;
ShapeEllipse.Visibility = shapeType == MarkupShapeType.Ellipse
? Visibility.Visible : Visibility.Collapsed;
}

private void UpdateColors()
{
SolidColorBrush brush = new(strokeColor);
ShapeLine.Stroke = brush;
ArrowHead.Fill = new SolidColorBrush(strokeColor);
ShapeRectangle.Stroke = new SolidColorBrush(strokeColor);
ShapeEllipse.Stroke = new SolidColorBrush(strokeColor);
Point1Handle.Fill = new SolidColorBrush(strokeColor);
Point2Handle.Fill = new SolidColorBrush(strokeColor);
}

private void UpdateThickness()
{
ShapeLine.StrokeThickness = strokeThickness;
ShapeRectangle.StrokeThickness = strokeThickness;
ShapeEllipse.StrokeThickness = strokeThickness;
}

private void UpdatePositions()
{
Canvas.SetLeft(Point1Handle, point1.X - Point1Handle.Width / 2);
Canvas.SetTop(Point1Handle, point1.Y - Point1Handle.Height / 2);
Canvas.SetLeft(Point2Handle, point2.X - Point2Handle.Width / 2);
Canvas.SetTop(Point2Handle, point2.Y - Point2Handle.Height / 2);

switch (shapeType)
{
case MarkupShapeType.Line:
case MarkupShapeType.Arrow:
ShapeLine.X1 = point1.X;
ShapeLine.Y1 = point1.Y;
ShapeLine.X2 = point2.X;
ShapeLine.Y2 = point2.Y;
if (shapeType == MarkupShapeType.Arrow)
UpdateArrowHead();
break;

case MarkupShapeType.Rectangle:
double rx = Math.Min(point1.X, point2.X);
double ry = Math.Min(point1.Y, point2.Y);
Canvas.SetLeft(ShapeRectangle, rx);
Canvas.SetTop(ShapeRectangle, ry);
ShapeRectangle.Width = Math.Max(1, Math.Abs(point2.X - point1.X));
ShapeRectangle.Height = Math.Max(1, Math.Abs(point2.Y - point1.Y));
break;

case MarkupShapeType.Ellipse:
double ex = Math.Min(point1.X, point2.X);
double ey = Math.Min(point1.Y, point2.Y);
Canvas.SetLeft(ShapeEllipse, ex);
Canvas.SetTop(ShapeEllipse, ey);
ShapeEllipse.Width = Math.Max(1, Math.Abs(point2.X - point1.X));
ShapeEllipse.Height = Math.Max(1, Math.Abs(point2.Y - point1.Y));
break;
}
}

private void UpdateArrowHead()
{
double dx = point2.X - point1.X;
double dy = point2.Y - point1.Y;
double len = Math.Sqrt(dx * dx + dy * dy);
if (len < 1) return;

double arrowSize = Math.Max(14, strokeThickness * 4);
double angle = Math.Atan2(dy, dx);

Point tip = point2;
Point b1 = new(
tip.X - arrowSize * Math.Cos(angle - Math.PI / 6),
tip.Y - arrowSize * Math.Sin(angle - Math.PI / 6));
Point b2 = new(
tip.X - arrowSize * Math.Cos(angle + Math.PI / 6),
tip.Y - arrowSize * Math.Sin(angle + Math.PI / 6));

ArrowHead.Points = [tip, b1, b2];
}

private void HandlePoint_MouseDown(object sender, MouseButtonEventArgs e)
{
if (sender is not Ellipse ellipse || ellipse.Tag is not string s)
return;
pointDraggingIndex = int.Parse(s);
MeasurementPointMouseDown?.Invoke(sender, e);
}

public void MovePoint(int pointIndex, Point newPosition)
{
if (pointIndex == 0) point1 = newPosition;
else if (pointIndex == 1) point2 = newPosition;
UpdatePositions();
}

public void StartDraggingPoint(int pointIndex)
{
pointDraggingIndex = pointIndex;
MeasurementPointMouseDown?.Invoke(
pointIndex == 0 ? Point1Handle : Point2Handle, null!);
}

public int GetActivePointIndex() => pointDraggingIndex;

public void ResetActivePoint() => pointDraggingIndex = -1;

public (Point Point1, Point Point2) GetPoints() => (point1, point2);

private void RemoveMenuItem_Click(object sender, RoutedEventArgs e)
{
RemoveControlRequested?.Invoke(this, EventArgs.Empty);
}

public MarkupShapeDto ToDto()
{
return new MarkupShapeDto
{
ShapeType = shapeType,
Point1 = point1,
Point2 = point2,
StrokeColor = strokeColor.ToString(),
StrokeThickness = strokeThickness
};
}

public void FromDto(MarkupShapeDto dto)
{
shapeType = dto.ShapeType;
point1 = dto.Point1;
point2 = dto.Point2;

try { strokeColor = (Color)ColorConverter.ConvertFromString(dto.StrokeColor); }
catch { strokeColor = Colors.Red; }

strokeThickness = dto.StrokeThickness;
UpdateShapeVisibility();
UpdateColors();
UpdateThickness();
UpdatePositions();
}
}
Loading
Loading