initial commit

This commit is contained in:
2026-05-02 01:01:15 +02:00
parent 591399495c
commit fc73ff47ff
945 changed files with 11285 additions and 0 deletions

25
src/TraceCad.App/App.cs Normal file
View File

@@ -0,0 +1,25 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Styling;
using Avalonia.Themes.Fluent;
namespace TraceCad.App;
public sealed class App : Application
{
public override void Initialize()
{
RequestedThemeVariant = ThemeVariant.Dark;
Styles.Add(new FluentTheme());
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}

View File

@@ -0,0 +1,739 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using TraceCad.App.Tools;
using TraceCad.Core.Model;
using TraceCad.Core.Serialization;
using TraceCad.Dxf;
using TraceCad.Vision.Calibration;
namespace TraceCad.App;
public sealed class MainWindow : Window
{
private static readonly SolidColorBrush AppBackground = Brush("#0b1020");
private static readonly SolidColorBrush ChromeBackground = Brush("#111827");
private static readonly SolidColorBrush GlassBackground = Brush("#0f172a");
private static readonly SolidColorBrush PanelBackground = Brush("#111827");
private static readonly SolidColorBrush RailBackground = Brush("#0f172a");
private static readonly SolidColorBrush RailButtonBackground = Brush("#1f2937");
private static readonly SolidColorBrush RailButtonSelectedBackground = Brush("#1d4ed8");
private static readonly SolidColorBrush StrokeBrush = Brush("#334155");
private static readonly SolidColorBrush GlassStroke = Brush("#475569");
private static readonly SolidColorBrush TextPrimary = Brush("#f8fafc");
private static readonly SolidColorBrush TextSecondary = Brush("#cbd5e1");
private static readonly SolidColorBrush TextMuted = Brush("#94a3b8");
private static readonly SolidColorBrush TextOnDark = Brush("#ffffff");
private static readonly SolidColorBrush Accent = Brush("#3b82f6");
private static readonly SolidColorBrush AccentSoft = Brush("#172554");
private readonly SketchCanvas _canvas = new();
private readonly TextBlock _status = Text("", TextSecondary, 12);
private readonly TextBlock _toolState = Text("", TextSecondary, 12);
private readonly TextBlock _referenceName = Text("", TextSecondary, 12);
private readonly Slider _referenceOpacity = new() { Minimum = 0.0, Maximum = 1.0, Value = 0.55 };
private readonly CheckBox _referenceLocked = new() { Content = "Locked", Foreground = TextPrimary };
private readonly NumericUpDown _referenceX = NumberInput(1.0);
private readonly NumericUpDown _referenceY = NumberInput(1.0);
private readonly NumericUpDown _referenceScale = NumberInput(0.01, minimum: 0.0001);
private readonly NumericUpDown _referenceRotation = NumberInput(1.0, minimum: -3600.0, maximum: 3600.0);
private readonly IDxfExporter _dxfExporter = new NetDxfExporter();
private readonly SheetCalibrator _sheetCalibrator = new();
private readonly Dictionary<string, (Button Button, string Icon)> _toolButtons = new();
private bool _syncingReferencePanel;
public MainWindow()
{
Title = "easyTrace";
Width = 1360;
Height = 860;
MinWidth = 980;
MinHeight = 640;
Background = AppBackground;
TransparencyLevelHint = new[] { WindowTransparencyLevel.None };
TransparencyBackgroundFallback = AppBackground;
_referenceOpacity.PropertyChanged += (_, e) =>
{
if (_syncingReferencePanel || e.Property != RangeBase.ValueProperty)
{
return;
}
_canvas.SetReferenceOpacity(_referenceOpacity.Value);
UpdateStatus();
};
_referenceLocked.PropertyChanged += (_, e) =>
{
if (_syncingReferencePanel || e.Property != ToggleButton.IsCheckedProperty)
{
return;
}
SetReferenceLockedFromPanel();
};
Content = BuildLayout();
_canvas.StatusChanged += (_, _) => UpdateStatus();
_canvas.Changed += (_, _) =>
{
UpdateStatus();
SyncReferencePanel();
ApplyToolButtonState();
};
UpdateStatus();
SyncReferencePanel();
ApplyToolButtonState();
}
private Control BuildLayout()
{
var root = new Grid
{
RowDefinitions = new RowDefinitions("76,*,34"),
Background = AppSurfaceBrush()
};
var topBar = AddTopBar();
Grid.SetRow(topBar, 0);
root.Children.Add(topBar);
var workArea = new Grid
{
ColumnDefinitions = new ColumnDefinitions("104,*,332"),
Background = Brushes.Transparent
};
Grid.SetRow(workArea, 1);
root.Children.Add(workArea);
var rail = BuildToolRail();
Grid.SetColumn(rail, 0);
workArea.Children.Add(rail);
var canvasHost = GlassPanel(
_canvas,
new Avalonia.Thickness(0, 10, 0, 14),
new Avalonia.CornerRadius(12),
GlassBackground);
canvasHost.ClipToBounds = true;
canvasHost.BorderThickness = new Avalonia.Thickness(1.4);
Grid.SetColumn(canvasHost, 1);
workArea.Children.Add(canvasHost);
var inspector = BuildInspector();
Grid.SetColumn(inspector, 2);
workArea.Children.Add(inspector);
var statusBar = BuildStatusBar();
Grid.SetRow(statusBar, 2);
root.Children.Add(statusBar);
return root;
}
private Border GlassPanel(Control child, Avalonia.Thickness margin, Avalonia.CornerRadius cornerRadius, IBrush? background = null)
{
return new Border
{
Margin = margin,
Background = background ?? GlassCardBrush(),
BorderBrush = GlassStroke,
BorderThickness = new Avalonia.Thickness(1),
CornerRadius = cornerRadius,
Child = child
};
}
private Control AddTopBar()
{
var dock = new DockPanel
{
Margin = new Avalonia.Thickness(18, 11),
LastChildFill = true
};
var brand = new StackPanel
{
Width = 205,
Spacing = 0,
VerticalAlignment = VerticalAlignment.Center
};
brand.Children.Add(Text("easyTrace", TextPrimary, 21, FontWeight.SemiBold));
brand.Children.Add(Text("Calibrated 2D tracing CAD", TextMuted, 11));
DockPanel.SetDock(brand, Dock.Left);
dock.Children.Add(brand);
var actions = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
VerticalAlignment = VerticalAlignment.Center
};
DockPanel.SetDock(actions, Dock.Right);
actions.Children.Add(CommandButton(UiIcon.New, "New", (_, _) => NewDocument()));
actions.Children.Add(CommandButton(UiIcon.Open, "Open", async (_, _) => await OpenProject()));
actions.Children.Add(CommandButton(UiIcon.Save, "Save", async (_, _) => await SaveProject()));
actions.Children.Add(CommandButton(UiIcon.Image, "Reference", async (_, _) => await ImportReference()));
actions.Children.Add(CommandButton(UiIcon.Calibrate, "Calibrate", async (_, _) => await CalibrateSheet()));
actions.Children.Add(PrimaryButton(UiIcon.Export, "Export DXF", async (_, _) => await ExportDxf()));
dock.Children.Add(actions);
var center = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
center.Children.Add(CommandButton(UiIcon.Undo, "Undo", (_, _) => _canvas.Undo()));
center.Children.Add(CommandButton(UiIcon.Redo, "Redo", (_, _) => _canvas.Redo()));
center.Children.Add(CommandButton(UiIcon.Delete, "Delete", (_, _) => _canvas.DeleteSelected()));
dock.Children.Add(center);
return GlassPanel(
dock,
new Avalonia.Thickness(14, 12, 14, 6),
new Avalonia.CornerRadius(14),
ChromeBackground);
}
private Control BuildToolRail()
{
var stack = new StackPanel
{
Margin = new Avalonia.Thickness(12, 14),
Spacing = 10
};
stack.Children.Add(Text("TOOLS", Brush("#94a3b8"), 10, FontWeight.SemiBold));
stack.Children.Add(ToolButton(UiIcon.Select, "Select", () => _canvas.SelectTool()));
stack.Children.Add(ToolButton(UiIcon.Line, "Line", () => _canvas.SetTool(new LineTool())));
stack.Children.Add(ToolButton(UiIcon.Circle, "Circle 3P", () => _canvas.SetTool(new Circle3PointTool())));
stack.Children.Add(ToolButton(UiIcon.Arc, "Arc 3P", () => _canvas.SetTool(new Arc3PointTool())));
return GlassPanel(
stack,
new Avalonia.Thickness(14, 10, 12, 14),
new Avalonia.CornerRadius(14),
RailBackground);
}
private Control BuildInspector()
{
var panel = new StackPanel
{
Spacing = 14,
Margin = new Avalonia.Thickness(18)
};
panel.Children.Add(SectionHeader("Reference Image"));
panel.Children.Add(_referenceName);
panel.Children.Add(LabeledControl("Opacity", _referenceOpacity));
panel.Children.Add(_referenceLocked);
panel.Children.Add(LabeledControl("X origin (mm)", _referenceX));
panel.Children.Add(LabeledControl("Y origin (mm)", _referenceY));
panel.Children.Add(LabeledControl("Scale (mm / image px)", _referenceScale));
panel.Children.Add(LabeledControl("Rotation (deg)", _referenceRotation));
panel.Children.Add(PrimaryButton(UiIcon.Apply, "Apply Transform", (_, _) => ApplyReferenceTransformFromPanel()));
panel.Children.Add(CommandButton(UiIcon.Clear, "Clear Reference", (_, _) => _canvas.ClearReference()));
panel.Children.Add(new Border
{
Height = 1,
Background = StrokeBrush,
Margin = new Avalonia.Thickness(0, 4)
});
panel.Children.Add(SectionHeader("Document"));
panel.Children.Add(DocumentMetric("Units", "mm"));
return GlassPanel(
panel,
new Avalonia.Thickness(0, 10, 14, 14),
new Avalonia.CornerRadius(14),
PanelBackground);
}
private Control BuildStatusBar()
{
var dock = new DockPanel
{
Margin = new Avalonia.Thickness(14, 0),
LastChildFill = true
};
DockPanel.SetDock(_toolState, Dock.Right);
dock.Children.Add(_toolState);
dock.Children.Add(_status);
return GlassPanel(
dock,
new Avalonia.Thickness(14, 0, 14, 8),
new Avalonia.CornerRadius(10),
ChromeBackground);
}
private Button ToolButton(string icon, string text, Action action)
{
var button = new Button
{
Content = ToolButtonContent(icon, text, selected: false),
Background = RailButtonBackground,
Foreground = TextPrimary,
BorderBrush = StrokeBrush,
BorderThickness = new Avalonia.Thickness(1),
Padding = new Avalonia.Thickness(5, 9),
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Center,
MinHeight = 66,
CornerRadius = new Avalonia.CornerRadius(10)
};
button.Click += (_, _) =>
{
action();
ApplyToolButtonState();
};
_toolButtons[text] = (button, icon);
return button;
}
private static Button CommandButton(string icon, string text, EventHandler<RoutedEventArgs> handler)
{
var button = new Button
{
Content = CommandButtonContent(icon, text, TextPrimary),
Background = Brush("#1f2937"),
Foreground = TextPrimary,
BorderBrush = StrokeBrush,
BorderThickness = new Avalonia.Thickness(1),
Padding = new Avalonia.Thickness(13, 8),
MinHeight = 38,
HorizontalContentAlignment = HorizontalAlignment.Center,
CornerRadius = new Avalonia.CornerRadius(9)
};
button.Click += handler;
return button;
}
private static Button PrimaryButton(string icon, string text, EventHandler<RoutedEventArgs> handler)
{
var button = CommandButton(icon, text, handler);
button.Content = CommandButtonContent(icon, text, TextOnDark);
button.Background = PrimaryAccentBrush();
button.Foreground = TextOnDark;
button.BorderBrush = Brush("#1d4ed8");
return button;
}
private static Control LabeledControl(string label, Control input)
{
return new StackPanel
{
Spacing = 5,
Children =
{
Text(label, TextSecondary, 11, FontWeight.SemiBold),
input
}
};
}
private static Control DocumentMetric(string label, string value)
{
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto")
};
var labelText = Text(label, TextSecondary, 12, FontWeight.SemiBold);
var valueText = Text(value, TextPrimary, 12, FontWeight.SemiBold);
Grid.SetColumn(valueText, 1);
grid.Children.Add(labelText);
grid.Children.Add(valueText);
return new Border
{
Background = GlassCardBrush(),
BorderBrush = Brush("#7fbfff44"),
BorderThickness = new Avalonia.Thickness(1),
CornerRadius = new Avalonia.CornerRadius(12),
Padding = new Avalonia.Thickness(12, 9),
Child = grid
};
}
private static StackPanel CommandButtonContent(string icon, string text, IBrush foreground)
{
return new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 7,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
IconText(icon, foreground, 14),
Text(text, foreground, 12, FontWeight.SemiBold)
}
};
}
private static StackPanel ToolButtonContent(string icon, string text, bool selected)
{
var foreground = selected ? Accent : TextSecondary;
return new StackPanel
{
Spacing = 5,
HorizontalAlignment = HorizontalAlignment.Center,
Children =
{
IconText(icon, foreground, 22),
Text(text, foreground, 10, FontWeight.SemiBold)
}
};
}
private static TextBlock IconText(string glyph, IBrush foreground, double size) =>
new()
{
Text = glyph,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
Foreground = foreground,
FontSize = size,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
private static TextBlock SectionHeader(string text) =>
Text(text, TextPrimary, 15, FontWeight.SemiBold);
private static TextBlock Text(string text, IBrush foreground, double size, FontWeight weight = FontWeight.Normal) =>
new()
{
Text = text,
Foreground = foreground,
FontSize = size,
FontWeight = weight,
TextWrapping = TextWrapping.Wrap
};
private void NewDocument()
{
_canvas.SetDocument(SketchDocument.CreateDefault());
UpdateStatus("New project");
}
private async Task ImportReference()
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
AllowMultiple = false,
Title = "Import reference image",
FileTypeFilter = new[] { ImageFileType }
});
var path = files.FirstOrDefault()?.TryGetLocalPath();
if (path is null)
{
return;
}
_canvas.ImportReferenceImage(path);
SyncReferencePanel();
}
private async Task CalibrateSheet()
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
AllowMultiple = false,
Title = "Calibrate reference sheet photo",
FileTypeFilter = new[] { ImageFileType }
});
var path = files.FirstOrDefault()?.TryGetLocalPath();
if (path is null)
{
return;
}
UpdateStatus("Calibrating reference sheet...");
try
{
var outputDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"easyTrace",
"calibrated-references");
var result = await Task.Run(() => _sheetCalibrator.Calibrate(path, outputDirectory));
if (!result.Success || result.CorrectedImagePath is null)
{
UpdateStatus(result.Message);
return;
}
_canvas.SetReferenceImage(new ReferenceImage(
result.CorrectedImagePath,
0.55,
Locked: true,
new ReferenceTransform(
OriginX: 0.0,
OriginY: 0.0,
ScaleX: result.MmPerPixel,
ScaleY: result.MmPerPixel,
RotationDeg: 0.0)));
SyncReferencePanel();
UpdateStatus(result.Message);
}
catch (Exception ex)
{
UpdateStatus($"Calibration failed: {ex.Message}");
}
}
private async Task OpenProject()
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
AllowMultiple = false,
Title = "Open easyTrace project",
FileTypeFilter = new[] { ProjectFileType }
});
var path = files.FirstOrDefault()?.TryGetLocalPath();
if (path is null)
{
return;
}
var json = await File.ReadAllTextAsync(path);
_canvas.SetDocument(SketchDocumentSerializer.Deserialize(json));
UpdateStatus($"Opened {Path.GetFileName(path)}");
}
private async Task SaveProject()
{
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Save easyTrace project",
SuggestedFileName = "drawing.easytrace.json",
FileTypeChoices = new[] { ProjectFileType }
});
var path = file?.TryGetLocalPath();
if (path is null)
{
return;
}
await File.WriteAllTextAsync(path, SketchDocumentSerializer.Serialize(_canvas.Document));
UpdateStatus($"Saved {Path.GetFileName(path)}");
}
private async Task ExportDxf()
{
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export DXF",
SuggestedFileName = "drawing.dxf",
FileTypeChoices = new[] { DxfFileType }
});
var path = file?.TryGetLocalPath();
if (path is null)
{
return;
}
_dxfExporter.Export(_canvas.Document, path);
UpdateStatus($"Exported {Path.GetFileName(path)}");
}
private void UpdateStatus(string? prefix = null)
{
_status.Text = prefix ?? _canvas.StatusText;
var reference = _canvas.Document.Reference is null ? "No reference" : "Reference loaded";
_toolState.Text = $"Tool: {_canvas.ActiveToolName} Entities: {_canvas.Document.Entities.Count} {reference}";
}
private void SyncReferencePanel()
{
_syncingReferencePanel = true;
var reference = _canvas.Document.Reference;
var hasReference = reference is not null;
_referenceName.Text = hasReference ? Path.GetFileName(reference!.ImagePath) : "No image imported";
_referenceOpacity.IsEnabled = hasReference;
_referenceLocked.IsEnabled = hasReference;
_referenceX.IsEnabled = hasReference && !reference!.Locked;
_referenceY.IsEnabled = hasReference && !reference!.Locked;
_referenceScale.IsEnabled = hasReference && !reference!.Locked;
_referenceRotation.IsEnabled = hasReference && !reference!.Locked;
if (reference is not null)
{
_referenceOpacity.Value = reference.Opacity;
_referenceLocked.IsChecked = reference.Locked;
_referenceX.Value = (decimal)reference.Transform.OriginX;
_referenceY.Value = (decimal)reference.Transform.OriginY;
_referenceScale.Value = (decimal)reference.Transform.ScaleX;
_referenceRotation.Value = (decimal)reference.Transform.RotationDeg;
}
else
{
_referenceLocked.IsChecked = false;
_referenceX.Value = 0m;
_referenceY.Value = 0m;
_referenceScale.Value = 1m;
_referenceRotation.Value = 0m;
}
_syncingReferencePanel = false;
}
private void SetReferenceLockedFromPanel()
{
if (_syncingReferencePanel)
{
return;
}
_canvas.SetReferenceLocked(_referenceLocked.IsChecked == true);
SyncReferencePanel();
}
private void ApplyReferenceTransformFromPanel()
{
if (_canvas.Document.Reference is null || _canvas.Document.Reference.Locked)
{
return;
}
var scale = ToDouble(_referenceScale.Value, 1.0);
_canvas.SetReferenceTransform(new ReferenceTransform(
ToDouble(_referenceX.Value, 0.0),
ToDouble(_referenceY.Value, 0.0),
scale,
scale,
ToDouble(_referenceRotation.Value, 0.0)));
SyncReferencePanel();
}
private void ApplyToolButtonState()
{
foreach (var (name, entry) in _toolButtons)
{
var selected = string.Equals(name, _canvas.ActiveToolName, StringComparison.Ordinal);
entry.Button.Background = selected ? RailButtonSelectedBackground : RailButtonBackground;
entry.Button.BorderBrush = selected ? Brush("#93c5fd") : StrokeBrush;
entry.Button.Content = ToolButtonContent(entry.Icon, name, selected);
}
}
private static double ToDouble(decimal? value, double fallback) =>
value is null ? fallback : (double)value.Value;
private static NumericUpDown NumberInput(double increment, double minimum = -100000.0, double maximum = 100000.0) =>
new()
{
Minimum = (decimal)minimum,
Maximum = (decimal)maximum,
Increment = (decimal)increment,
FormatString = "0.####",
HorizontalAlignment = HorizontalAlignment.Stretch,
Background = Brush("#0f172a"),
Foreground = TextPrimary,
BorderBrush = StrokeBrush,
BorderThickness = new Avalonia.Thickness(1),
CornerRadius = new Avalonia.CornerRadius(7)
};
private static IBrush AppSurfaceBrush() =>
new LinearGradientBrush
{
StartPoint = new Avalonia.RelativePoint(0.0, 0.0, Avalonia.RelativeUnit.Relative),
EndPoint = new Avalonia.RelativePoint(1.0, 1.0, Avalonia.RelativeUnit.Relative),
GradientStops =
{
new GradientStop(Color.Parse("#0b1020"), 0.0),
new GradientStop(Color.Parse("#111827"), 0.52),
new GradientStop(Color.Parse("#0f172a"), 1.0)
}
};
private static IBrush PrimaryAccentBrush() =>
new LinearGradientBrush
{
StartPoint = new Avalonia.RelativePoint(0.0, 0.0, Avalonia.RelativeUnit.Relative),
EndPoint = new Avalonia.RelativePoint(1.0, 1.0, Avalonia.RelativeUnit.Relative),
GradientStops =
{
new GradientStop(Color.Parse("#60a5fa"), 0.0),
new GradientStop(Color.Parse("#3b82f6"), 0.5),
new GradientStop(Color.Parse("#1d4ed8"), 1.0)
}
};
private static IBrush GlassCardBrush() =>
new LinearGradientBrush
{
StartPoint = new Avalonia.RelativePoint(0.0, 0.0, Avalonia.RelativeUnit.Relative),
EndPoint = new Avalonia.RelativePoint(1.0, 1.0, Avalonia.RelativeUnit.Relative),
GradientStops =
{
new GradientStop(Color.Parse("#1e293b"), 0.0),
new GradientStop(Color.Parse("#111827"), 0.55),
new GradientStop(Color.Parse("#0f172a"), 1.0)
}
};
private static SolidColorBrush Brush(string color) => new(Color.Parse(color));
private static class UiIcon
{
public const string New = "\uE710";
public const string Open = "\uE838";
public const string Save = "\uE74E";
public const string Image = "\uE91B";
public const string Calibrate = "\uE9F9";
public const string Export = "\uE898";
public const string Undo = "\uE7A7";
public const string Redo = "\uE7A6";
public const string Delete = "\uE74D";
public const string Select = "\uE7C9";
public const string Line = "\uE738";
public const string Circle = "\uEA3A";
public const string Arc = "\uE7C1";
public const string Apply = "\uE73E";
public const string Clear = "\uE711";
}
private static FilePickerFileType ProjectFileType { get; } = new("easyTrace project")
{
Patterns = new[] { "*.easytrace.json", "*.tracecad.json", "*.json" }
};
private static FilePickerFileType DxfFileType { get; } = new("DXF")
{
Patterns = new[] { "*.dxf" }
};
private static FilePickerFileType ImageFileType { get; } = new("Images")
{
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.bmp", "*.tif", "*.tiff", "*.webp" }
};
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (!e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
return;
}
if (e.Key == Key.S)
{
_ = SaveProject();
e.Handled = true;
}
}
}

View File

@@ -0,0 +1,18 @@
using Avalonia;
namespace TraceCad.App;
internal static class Program
{
[STAThread]
public static void Main(string[] args)
{
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
}

View File

@@ -0,0 +1,495 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media.Imaging;
using Avalonia.Media;
using TraceCad.App.Tools;
using TraceCad.Core.Commands;
using TraceCad.Core.Geometry;
using TraceCad.Core.Model;
namespace TraceCad.App;
public sealed class SketchCanvas : Control
{
private readonly SolidColorBrush _canvasBackground = new(Color.FromRgb(8, 13, 24));
private readonly Pen _gridPen = new(new SolidColorBrush(Color.FromRgb(30, 41, 59)), 1.0);
private readonly Pen _axisPen = new(new SolidColorBrush(Color.FromRgb(71, 85, 105)), 1.3);
private readonly Pen _entityPen = new(new SolidColorBrush(Color.FromRgb(226, 232, 240)), 1.7);
private readonly Pen _selectedPen = new(new SolidColorBrush(Color.FromRgb(96, 165, 250)), 2.6);
private readonly ViewTransform _transform = new();
private readonly SelectTool _selectTool = new();
private bool _isPanning;
private Point _lastPanPoint;
private Point2? _hoverPoint;
private Entity? _selectedEntity;
private ISketchTool _activeTool;
private Bitmap? _referenceBitmap;
public SketchCanvas()
{
Focusable = true;
ClipToBounds = true;
Document = SketchDocument.CreateDefault();
Commands = new CommandManager();
_activeTool = _selectTool;
StatusText = _activeTool.Prompt;
}
public event EventHandler? Changed;
public event EventHandler? StatusChanged;
public SketchDocument Document { get; private set; }
public CommandManager Commands { get; }
public string StatusText { get; private set; }
public string ActiveToolName => _activeTool.Name;
public void SetDocument(SketchDocument document)
{
Document = document;
Commands.Clear();
_selectedEntity = null;
_transform.Reset();
LoadReferenceBitmap();
SetStatus(_activeTool.Prompt);
Changed?.Invoke(this, EventArgs.Empty);
InvalidateVisual();
}
public void ImportReferenceImage(string imagePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
_referenceBitmap?.Dispose();
_referenceBitmap = new Bitmap(imagePath);
var bitmapSize = _referenceBitmap.Size;
var defaultScale = bitmapSize.Width > 0.0 ? Document.Width / bitmapSize.Width : 1.0;
Document.Reference = new ReferenceImage(
imagePath,
0.55,
Locked: true,
new ReferenceTransform(
OriginX: 0.0,
OriginY: 0.0,
ScaleX: defaultScale,
ScaleY: defaultScale,
RotationDeg: 0.0));
SetStatus($"Reference imported: {Path.GetFileName(imagePath)}");
Changed?.Invoke(this, EventArgs.Empty);
InvalidateVisual();
}
public void SetReferenceImage(ReferenceImage reference)
{
ArgumentNullException.ThrowIfNull(reference);
Document.Reference = reference;
LoadReferenceBitmap();
SetStatus($"Reference loaded: {Path.GetFileName(reference.ImagePath)}");
Changed?.Invoke(this, EventArgs.Empty);
InvalidateVisual();
}
public void SetReferenceOpacity(double opacity)
{
if (Document.Reference is null)
{
return;
}
Document.Reference = Document.Reference with { Opacity = Math.Clamp(opacity, 0.0, 1.0) };
Changed?.Invoke(this, EventArgs.Empty);
InvalidateVisual();
}
public void SetReferenceLocked(bool locked)
{
if (Document.Reference is null)
{
return;
}
Document.Reference = Document.Reference with { Locked = locked };
SetStatus(locked ? "Reference locked" : "Reference unlocked");
Changed?.Invoke(this, EventArgs.Empty);
InvalidateVisual();
}
public void SetReferenceTransform(ReferenceTransform transform)
{
if (Document.Reference is null || Document.Reference.Locked)
{
return;
}
Document.Reference = Document.Reference with
{
Transform = transform with
{
ScaleX = Math.Max(0.0001, transform.ScaleX),
ScaleY = Math.Max(0.0001, transform.ScaleY)
}
};
SetStatus("Reference transform updated");
Changed?.Invoke(this, EventArgs.Empty);
InvalidateVisual();
}
public void ClearReference()
{
Document.Reference = null;
_referenceBitmap?.Dispose();
_referenceBitmap = null;
SetStatus("Reference cleared");
Changed?.Invoke(this, EventArgs.Empty);
InvalidateVisual();
}
public void SetTool(ISketchTool tool)
{
_activeTool.Cancel(CreateToolContext());
_activeTool = tool;
SetStatus(tool.Prompt);
Changed?.Invoke(this, EventArgs.Empty);
InvalidateVisual();
}
public void SelectTool() => SetTool(_selectTool);
public void DeleteSelected()
{
if (_selectedEntity is null)
{
return;
}
Commands.Execute(Document, new DeleteEntityCommand(_selectedEntity));
_selectedEntity = null;
SetStatus(_activeTool.Prompt);
Changed?.Invoke(this, EventArgs.Empty);
InvalidateVisual();
}
public void Undo()
{
Commands.Undo(Document);
_selectedEntity = null;
Changed?.Invoke(this, EventArgs.Empty);
InvalidateVisual();
}
public void Redo()
{
Commands.Redo(Document);
_selectedEntity = null;
Changed?.Invoke(this, EventArgs.Empty);
InvalidateVisual();
}
public override void Render(DrawingContext context)
{
base.Render(context);
context.DrawRectangle(_canvasBackground, null, Bounds);
DrawReferenceImage(context);
DrawGrid(context);
foreach (var entity in Document.Entities)
{
DrawEntity(context, entity, entity.Id == _selectedEntity?.Id);
}
_activeTool.RenderOverlay(context, new ToolRenderContext(_transform, _hoverPoint, _transform.Scale));
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
Focus();
var point = e.GetPosition(this);
var properties = e.GetCurrentPoint(this).Properties;
if (properties.IsMiddleButtonPressed || properties.IsRightButtonPressed)
{
BeginPan(e, point);
return;
}
if (properties.IsLeftButtonPressed)
{
_activeTool.PointerDown(CreateToolContext(), _transform.ScreenToModel(point));
Changed?.Invoke(this, EventArgs.Empty);
}
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
var point = e.GetPosition(this);
if (_isPanning)
{
_transform.Pan(point - _lastPanPoint);
_lastPanPoint = point;
InvalidateVisual();
return;
}
_hoverPoint = SnapPoint(_transform.ScreenToModel(point));
_activeTool.PointerMove(CreateToolContext(), _transform.ScreenToModel(point));
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
if (_isPanning)
{
_isPanning = false;
e.Pointer.Capture(null);
return;
}
_activeTool.PointerUp(CreateToolContext(), _transform.ScreenToModel(e.GetPosition(this)));
}
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
base.OnPointerWheelChanged(e);
var factor = e.Delta.Y > 0 ? 1.15 : 1.0 / 1.15;
_transform.ZoomAt(e.GetPosition(this), factor);
InvalidateVisual();
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (e.Key == Key.Escape)
{
_activeTool.Cancel(CreateToolContext());
e.Handled = true;
return;
}
if (e.Key == Key.Delete)
{
DeleteSelected();
e.Handled = true;
return;
}
if (e.KeyModifiers.HasFlag(KeyModifiers.Control) &&
(e.Key == Key.Y || (e.KeyModifiers.HasFlag(KeyModifiers.Shift) && e.Key == Key.Z)))
{
Redo();
e.Handled = true;
return;
}
if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.Z)
{
Undo();
e.Handled = true;
return;
}
_activeTool.KeyDown(CreateToolContext(), e.Key);
}
private void DrawGrid(DrawingContext context)
{
var bounds = Bounds;
var topLeft = _transform.ScreenToModel(new Point(0.0, 0.0));
var bottomRight = _transform.ScreenToModel(new Point(bounds.Width, bounds.Height));
var spacing = GridSpacingMm();
var startX = Math.Floor(topLeft.X / spacing) * spacing;
var endX = Math.Ceiling(bottomRight.X / spacing) * spacing;
var startY = Math.Floor(topLeft.Y / spacing) * spacing;
var endY = Math.Ceiling(bottomRight.Y / spacing) * spacing;
for (var x = startX; x <= endX; x += spacing)
{
var p1 = _transform.ModelToScreen(new Point2(x, topLeft.Y));
var p2 = _transform.ModelToScreen(new Point2(x, bottomRight.Y));
context.DrawLine(Math.Abs(x) < GeometryConstants.Epsilon ? _axisPen : _gridPen, p1, p2);
}
for (var y = startY; y <= endY; y += spacing)
{
var p1 = _transform.ModelToScreen(new Point2(topLeft.X, y));
var p2 = _transform.ModelToScreen(new Point2(bottomRight.X, y));
context.DrawLine(Math.Abs(y) < GeometryConstants.Epsilon ? _axisPen : _gridPen, p1, p2);
}
}
private void DrawReferenceImage(DrawingContext context)
{
if (_referenceBitmap is null || Document.Reference is null)
{
return;
}
var reference = Document.Reference;
var transform = reference.Transform;
var origin = _transform.ModelToScreen(new Point2(transform.OriginX, transform.OriginY));
var width = _referenceBitmap.Size.Width * transform.ScaleX * _transform.Scale;
var height = _referenceBitmap.Size.Height * transform.ScaleY * _transform.Scale;
if (width <= 0.0 || height <= 0.0)
{
return;
}
using var opacity = context.PushOpacity(reference.Opacity);
using var translate = context.PushTransform(Matrix.CreateTranslation(origin.X, origin.Y));
using var rotate = context.PushTransform(Matrix.CreateRotation(transform.RotationDeg * Math.PI / 180.0));
context.DrawImage(_referenceBitmap, new Rect(0.0, 0.0, width, height));
}
private double GridSpacingMm()
{
var spacing = 5.0;
while (spacing * _transform.Scale < 18.0)
{
spacing *= 2.0;
}
return spacing;
}
private void DrawEntity(DrawingContext context, Entity entity, bool selected)
{
var pen = selected ? _selectedPen : _entityPen;
switch (entity)
{
case LineEntity line:
context.DrawLine(pen, _transform.ModelToScreen(line.Start), _transform.ModelToScreen(line.End));
break;
case CircleEntity circle:
ToolDrawing.DrawCircle(context, _transform, circle.Center, circle.Radius, pen);
break;
case ArcEntity arc:
ToolDrawing.DrawArc(context, _transform, arc.Center, arc.Radius, arc.StartAngleDeg, arc.EndAngleDeg, arc.IsClockwise, pen);
break;
}
}
private Point2 SnapPoint(Point2 point)
{
var toleranceMm = 8.0 / _transform.Scale;
Point2? best = null;
var bestDistance = double.MaxValue;
foreach (var snapPoint in Document.Entities.SelectMany(entity => entity.SnapPoints))
{
var distance = point.DistanceTo(snapPoint);
if (distance < bestDistance && distance <= toleranceMm)
{
best = snapPoint;
bestDistance = distance;
}
}
return best ?? point;
}
private Entity? HitTest(Point2 point, double tolerancePixels)
{
var toleranceMm = tolerancePixels / _transform.Scale;
return Document.Entities.LastOrDefault(entity => DistanceToEntity(point, entity) <= toleranceMm);
}
private static double DistanceToEntity(Point2 point, Entity entity)
{
return entity switch
{
LineEntity line => DistanceToSegment(point, line.Start, line.End),
CircleEntity circle => Math.Abs(point.DistanceTo(circle.Center) - circle.Radius),
ArcEntity arc => DistanceToArc(point, arc),
_ => double.MaxValue
};
}
private static double DistanceToSegment(Point2 point, Point2 start, Point2 end)
{
var segment = end - start;
var lengthSquared = segment.Dot(segment);
if (lengthSquared <= GeometryConstants.Epsilon)
{
return point.DistanceTo(start);
}
var t = Math.Clamp((point - start).Dot(segment) / lengthSquared, 0.0, 1.0);
var projected = start + (segment * t);
return point.DistanceTo(projected);
}
private static double DistanceToArc(Point2 point, ArcEntity arc)
{
var angle = GeometryHelpers.AngleDeg(arc.Center, point);
if (!GeometryHelpers.AngleIsOnSweep(arc.StartAngleDeg, arc.EndAngleDeg, angle, arc.IsClockwise))
{
return Math.Min(point.DistanceTo(arc.StartPoint), point.DistanceTo(arc.EndPoint));
}
return Math.Abs(point.DistanceTo(arc.Center) - arc.Radius);
}
private ToolContext CreateToolContext()
{
return new ToolContext(
Document,
Commands,
SnapPoint,
HitTest,
() => _selectedEntity,
entity =>
{
_selectedEntity = entity;
Changed?.Invoke(this, EventArgs.Empty);
},
SetStatus,
InvalidateVisual);
}
private void SetStatus(string status)
{
StatusText = status;
StatusChanged?.Invoke(this, EventArgs.Empty);
}
private void BeginPan(PointerPressedEventArgs e, Point point)
{
_isPanning = true;
_lastPanPoint = point;
e.Pointer.Capture(this);
SetStatus("Pan: drag to move canvas");
}
private void LoadReferenceBitmap()
{
_referenceBitmap?.Dispose();
_referenceBitmap = null;
if (Document.Reference is null)
{
return;
}
try
{
_referenceBitmap = new Bitmap(Document.Reference.ImagePath);
}
catch
{
SetStatus($"Reference image not found: {Document.Reference.ImagePath}");
}
}
}

View File

@@ -0,0 +1,107 @@
using Avalonia.Input;
using Avalonia.Media;
using TraceCad.Core.Commands;
using TraceCad.Core.Geometry;
using TraceCad.Core.Model;
namespace TraceCad.App.Tools;
public sealed class Arc3PointTool : ISketchTool
{
private readonly List<Point2> _points = new();
private Point2? _current;
public string Name => "Arc 3P";
public string Prompt => _points.Count switch
{
0 => "Arc 3P: choose start point",
1 => "Arc 3P: choose point on arc",
_ => "Arc 3P: choose end point"
};
public void PointerDown(ToolContext context, Point2 modelPoint)
{
_points.Add(context.Snap(modelPoint));
if (_points.Count == 3)
{
if (GeometryHelpers.TryCreateArcFromThreePoints(_points[0], _points[1], _points[2], out var arc))
{
context.Commands.Execute(
context.Document,
new AddEntityCommand(new ArcEntity(
Guid.NewGuid(),
Layer.Cut.Name,
arc.Center,
arc.Radius,
arc.StartAngleDeg,
arc.EndAngleDeg,
arc.IsClockwise)));
context.SetStatus("Arc 3P: arc created");
}
else
{
context.SetStatus("Arc 3P: points are collinear");
}
_points.Clear();
}
else
{
context.SetStatus(Prompt);
}
context.Invalidate();
}
public void PointerMove(ToolContext context, Point2 modelPoint)
{
_current = context.Snap(modelPoint);
context.Invalidate();
}
public void PointerUp(ToolContext context, Point2 modelPoint)
{
}
public void KeyDown(ToolContext context, Key key)
{
}
public void Cancel(ToolContext context)
{
_points.Clear();
_current = null;
context.SetStatus(Prompt);
context.Invalidate();
}
public void RenderOverlay(DrawingContext context, ToolRenderContext renderContext)
{
foreach (var point in _points)
{
ToolDrawing.DrawPointMarker(context, renderContext.Transform, point);
}
if (_points.Count == 2 && _current is not null)
{
var pen = GeometryHelpers.TryCreateArcFromThreePoints(_points[0], _points[1], _current.Value, out var arc)
? ToolDrawing.PreviewPen
: ToolDrawing.InvalidPen;
if (arc.Radius > GeometryConstants.Epsilon)
{
ToolDrawing.DrawArc(
context,
renderContext.Transform,
arc.Center,
arc.Radius,
arc.StartAngleDeg,
arc.EndAngleDeg,
arc.IsClockwise,
pen);
}
}
}
}

View File

@@ -0,0 +1,92 @@
using Avalonia.Input;
using Avalonia.Media;
using TraceCad.Core.Commands;
using TraceCad.Core.Geometry;
using TraceCad.Core.Model;
namespace TraceCad.App.Tools;
public sealed class Circle3PointTool : ISketchTool
{
private readonly List<Point2> _points = new();
private Point2? _current;
public string Name => "Circle 3P";
public string Prompt => _points.Count switch
{
0 => "Circle 3P: choose first point on circle",
1 => "Circle 3P: choose second point on circle",
_ => "Circle 3P: choose third point on circle"
};
public void PointerDown(ToolContext context, Point2 modelPoint)
{
_points.Add(context.Snap(modelPoint));
if (_points.Count == 3)
{
if (GeometryHelpers.TryCreateCircleFromThreePoints(_points[0], _points[1], _points[2], out var circle))
{
context.Commands.Execute(
context.Document,
new AddEntityCommand(new CircleEntity(Guid.NewGuid(), Layer.Cut.Name, circle.Center, circle.Radius)));
context.SetStatus("Circle 3P: circle created");
}
else
{
context.SetStatus("Circle 3P: points are collinear");
}
_points.Clear();
}
else
{
context.SetStatus(Prompt);
}
context.Invalidate();
}
public void PointerMove(ToolContext context, Point2 modelPoint)
{
_current = context.Snap(modelPoint);
context.Invalidate();
}
public void PointerUp(ToolContext context, Point2 modelPoint)
{
}
public void KeyDown(ToolContext context, Key key)
{
}
public void Cancel(ToolContext context)
{
_points.Clear();
_current = null;
context.SetStatus(Prompt);
context.Invalidate();
}
public void RenderOverlay(DrawingContext context, ToolRenderContext renderContext)
{
foreach (var point in _points)
{
ToolDrawing.DrawPointMarker(context, renderContext.Transform, point);
}
if (_points.Count == 2 && _current is not null)
{
var pen = GeometryHelpers.TryCreateCircleFromThreePoints(_points[0], _points[1], _current.Value, out var circle)
? ToolDrawing.PreviewPen
: ToolDrawing.InvalidPen;
if (circle.Radius > GeometryConstants.Epsilon)
{
ToolDrawing.DrawCircle(context, renderContext.Transform, circle.Center, circle.Radius, pen);
}
}
}
}

View File

@@ -0,0 +1,24 @@
using Avalonia.Input;
using Avalonia.Media;
using TraceCad.Core.Geometry;
namespace TraceCad.App.Tools;
public interface ISketchTool
{
string Name { get; }
string Prompt { get; }
void PointerDown(ToolContext context, Point2 modelPoint);
void PointerMove(ToolContext context, Point2 modelPoint);
void PointerUp(ToolContext context, Point2 modelPoint);
void KeyDown(ToolContext context, Key key);
void Cancel(ToolContext context);
void RenderOverlay(DrawingContext context, ToolRenderContext renderContext);
}

View File

@@ -0,0 +1,81 @@
using Avalonia.Input;
using Avalonia.Media;
using TraceCad.Core.Commands;
using TraceCad.Core.Geometry;
using TraceCad.Core.Model;
namespace TraceCad.App.Tools;
public sealed class LineTool : ISketchTool
{
private Point2? _start;
private Point2? _current;
public string Name => "Line";
public string Prompt => _start is null ? "Line Tool: choose start point" : "Line Tool: choose end point";
public void PointerDown(ToolContext context, Point2 modelPoint)
{
var point = context.Snap(modelPoint);
if (_start is null)
{
_start = point;
_current = point;
context.SetStatus(Prompt);
context.Invalidate();
return;
}
if (_start.Value.DistanceTo(point) > GeometryConstants.Epsilon)
{
context.Commands.Execute(
context.Document,
new AddEntityCommand(new LineEntity(Guid.NewGuid(), Layer.Cut.Name, _start.Value, point)));
}
_start = null;
_current = null;
context.SetStatus(Prompt);
context.Invalidate();
}
public void PointerMove(ToolContext context, Point2 modelPoint)
{
_current = context.Snap(modelPoint);
context.Invalidate();
}
public void PointerUp(ToolContext context, Point2 modelPoint)
{
}
public void KeyDown(ToolContext context, Key key)
{
}
public void Cancel(ToolContext context)
{
_start = null;
_current = null;
context.SetStatus(Prompt);
context.Invalidate();
}
public void RenderOverlay(DrawingContext context, ToolRenderContext renderContext)
{
if (_start is null)
{
return;
}
ToolDrawing.DrawPointMarker(context, renderContext.Transform, _start.Value);
if (_current is not null)
{
context.DrawLine(
ToolDrawing.PreviewPen,
renderContext.Transform.ModelToScreen(_start.Value),
renderContext.Transform.ModelToScreen(_current.Value));
}
}
}

View File

@@ -0,0 +1,42 @@
using Avalonia.Input;
using Avalonia.Media;
using TraceCad.Core.Geometry;
namespace TraceCad.App.Tools;
public sealed class SelectTool : ISketchTool
{
public string Name => "Select";
public string Prompt => "Select Tool: choose an entity";
public void PointerDown(ToolContext context, Point2 modelPoint)
{
context.SetSelected(context.HitTest(modelPoint, 6.0));
context.SetStatus(context.SelectedEntity is null ? Prompt : "Select Tool: entity selected");
context.Invalidate();
}
public void PointerMove(ToolContext context, Point2 modelPoint)
{
}
public void PointerUp(ToolContext context, Point2 modelPoint)
{
}
public void KeyDown(ToolContext context, Key key)
{
}
public void Cancel(ToolContext context)
{
context.SetSelected(null);
context.SetStatus(Prompt);
context.Invalidate();
}
public void RenderOverlay(DrawingContext context, ToolRenderContext renderContext)
{
}
}

View File

@@ -0,0 +1,32 @@
using TraceCad.Core.Commands;
using TraceCad.Core.Geometry;
using TraceCad.Core.Model;
namespace TraceCad.App.Tools;
public sealed class ToolContext(
SketchDocument document,
CommandManager commands,
Func<Point2, Point2> snapPoint,
Func<Point2, double, Entity?> hitTest,
Func<Entity?> getSelected,
Action<Entity?> setSelected,
Action<string> setStatus,
Action invalidate)
{
public SketchDocument Document { get; } = document;
public CommandManager Commands { get; } = commands;
public Point2 Snap(Point2 point) => snapPoint(point);
public Entity? HitTest(Point2 point, double tolerancePixels) => hitTest(point, tolerancePixels);
public Entity? SelectedEntity => getSelected();
public void SetSelected(Entity? entity) => setSelected(entity);
public void SetStatus(string status) => setStatus(status);
public void Invalidate() => invalidate();
}

View File

@@ -0,0 +1,68 @@
using Avalonia;
using Avalonia.Media;
using TraceCad.Core.Geometry;
namespace TraceCad.App.Tools;
internal static class ToolDrawing
{
public static readonly Pen PreviewPen = new(Brushes.DeepSkyBlue, 1.5);
public static readonly Pen InvalidPen = new(Brushes.OrangeRed, 1.5);
public static void DrawPointMarker(DrawingContext context, ViewTransform transform, Point2 point)
{
var screen = transform.ModelToScreen(point);
context.DrawEllipse(Brushes.White, new Pen(Brushes.DeepSkyBlue, 1.2), screen, 4.0, 4.0);
}
public static void DrawCircle(DrawingContext context, ViewTransform transform, Point2 center, double radius, Pen pen)
{
var screen = transform.ModelToScreen(center);
context.DrawEllipse(null, pen, screen, radius * transform.Scale, radius * transform.Scale);
}
public static void DrawArc(
DrawingContext context,
ViewTransform transform,
Point2 center,
double radius,
double startAngleDeg,
double endAngleDeg,
bool isClockwise,
Pen pen)
{
var sweep = isClockwise
? GeometryHelpers.ClockwiseSweepDeg(startAngleDeg, endAngleDeg)
: GeometryHelpers.CounterClockwiseSweepDeg(startAngleDeg, endAngleDeg);
if (sweep <= GeometryConstants.Epsilon)
{
return;
}
var start = PointAtAngle(transform, center, radius, startAngleDeg);
var end = PointAtAngle(transform, center, radius, endAngleDeg);
var geometry = new StreamGeometry();
using (var stream = geometry.Open())
{
stream.BeginFigure(start, isFilled: false);
stream.ArcTo(
end,
new Size(radius * transform.Scale, radius * transform.Scale),
0.0,
sweep > 180.0,
isClockwise ? SweepDirection.CounterClockwise : SweepDirection.Clockwise);
}
context.DrawGeometry(null, pen, geometry);
}
private static Point PointAtAngle(ViewTransform transform, Point2 center, double radius, double angleDeg)
{
var radians = angleDeg * Math.PI / 180.0;
return transform.ModelToScreen(new Point2(
center.X + (Math.Cos(radians) * radius),
center.Y + (Math.Sin(radians) * radius)));
}
}

View File

@@ -0,0 +1,6 @@
using Avalonia;
using TraceCad.Core.Geometry;
namespace TraceCad.App.Tools;
public sealed record ToolRenderContext(ViewTransform Transform, Point2? HoverPoint, double Scale);

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<AssemblyName>easyTrace</AssemblyName>
<ApplicationTitle>easyTrace</ApplicationTitle>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TraceCad.Core\TraceCad.Core.csproj" />
<ProjectReference Include="..\TraceCad.Dxf\TraceCad.Dxf.csproj" />
<ProjectReference Include="..\TraceCad.Vision\TraceCad.Vision.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.3" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,35 @@
using Avalonia;
using TraceCad.Core.Geometry;
namespace TraceCad.App;
public sealed class ViewTransform
{
public double Scale { get; private set; } = 3.0;
public Vector Offset { get; private set; } = new(80.0, 80.0);
public Point ModelToScreen(Point2 model) =>
new((model.X * Scale) + Offset.X, (model.Y * Scale) + Offset.Y);
public Point2 ScreenToModel(Point screen) =>
new((screen.X - Offset.X) / Scale, (screen.Y - Offset.Y) / Scale);
public void Pan(Vector screenDelta)
{
Offset += screenDelta;
}
public void ZoomAt(Point screenPoint, double factor)
{
var before = ScreenToModel(screenPoint);
Scale = Math.Clamp(Scale * factor, 0.05, 1000.0);
Offset = new Vector(screenPoint.X - (before.X * Scale), screenPoint.Y - (before.Y * Scale));
}
public void Reset()
{
Scale = 3.0;
Offset = new Vector(80.0, 80.0);
}
}

View File

@@ -0,0 +1,14 @@
using TraceCad.Core.Model;
namespace TraceCad.Core.Commands;
public sealed class AddEntityCommand(Entity entity) : IDocumentCommand
{
public string Name => "Add Entity";
public Entity Entity { get; } = entity;
public void Apply(SketchDocument document) => document.AddEntity(Entity);
public void Revert(SketchDocument document) => document.RemoveEntity(Entity.Id);
}

View File

@@ -0,0 +1,50 @@
using TraceCad.Core.Model;
namespace TraceCad.Core.Commands;
public sealed class CommandManager
{
private readonly Stack<IDocumentCommand> _undo = new();
private readonly Stack<IDocumentCommand> _redo = new();
public bool CanUndo => _undo.Count > 0;
public bool CanRedo => _redo.Count > 0;
public void Execute(SketchDocument document, IDocumentCommand command)
{
command.Apply(document);
_undo.Push(command);
_redo.Clear();
}
public void Undo(SketchDocument document)
{
if (!CanUndo)
{
return;
}
var command = _undo.Pop();
command.Revert(document);
_redo.Push(command);
}
public void Redo(SketchDocument document)
{
if (!CanRedo)
{
return;
}
var command = _redo.Pop();
command.Apply(document);
_undo.Push(command);
}
public void Clear()
{
_undo.Clear();
_redo.Clear();
}
}

View File

@@ -0,0 +1,14 @@
using TraceCad.Core.Model;
namespace TraceCad.Core.Commands;
public sealed class DeleteEntityCommand(Entity entity) : IDocumentCommand
{
public string Name => "Delete Entity";
public Entity Entity { get; } = entity;
public void Apply(SketchDocument document) => document.RemoveEntity(Entity.Id);
public void Revert(SketchDocument document) => document.AddEntity(Entity);
}

View File

@@ -0,0 +1,12 @@
using TraceCad.Core.Model;
namespace TraceCad.Core.Commands;
public interface IDocumentCommand
{
string Name { get; }
void Apply(SketchDocument document);
void Revert(SketchDocument document);
}

View File

@@ -0,0 +1,8 @@
namespace TraceCad.Core.Geometry;
public readonly record struct ArcDefinition(
Point2 Center,
double Radius,
double StartAngleDeg,
double EndAngleDeg,
bool IsClockwise);

View File

@@ -0,0 +1,3 @@
namespace TraceCad.Core.Geometry;
public readonly record struct CircleDefinition(Point2 Center, double Radius);

View File

@@ -0,0 +1,6 @@
namespace TraceCad.Core.Geometry;
public static class GeometryConstants
{
public const double Epsilon = 1e-9;
}

View File

@@ -0,0 +1,106 @@
namespace TraceCad.Core.Geometry;
public static class GeometryHelpers
{
public static double NormalizeAngleDeg(double angleDeg)
{
var normalized = angleDeg % 360.0;
return normalized < 0.0 ? normalized + 360.0 : normalized;
}
public static double AngleDeg(Point2 center, Point2 point)
{
return NormalizeAngleDeg(Math.Atan2(point.Y - center.Y, point.X - center.X) * 180.0 / Math.PI);
}
public static bool TryCreateCircleFromThreePoints(
Point2 first,
Point2 second,
Point2 third,
out CircleDefinition circle,
double epsilon = 1e-8)
{
var d = 2.0 * (
first.X * (second.Y - third.Y) +
second.X * (third.Y - first.Y) +
third.X * (first.Y - second.Y));
if (Math.Abs(d) < epsilon)
{
circle = default;
return false;
}
var firstSq = (first.X * first.X) + (first.Y * first.Y);
var secondSq = (second.X * second.X) + (second.Y * second.Y);
var thirdSq = (third.X * third.X) + (third.Y * third.Y);
var center = new Point2(
((firstSq * (second.Y - third.Y)) +
(secondSq * (third.Y - first.Y)) +
(thirdSq * (first.Y - second.Y))) / d,
((firstSq * (third.X - second.X)) +
(secondSq * (first.X - third.X)) +
(thirdSq * (second.X - first.X))) / d);
var radius = center.DistanceTo(first);
if (radius < epsilon)
{
circle = default;
return false;
}
circle = new CircleDefinition(center, radius);
return true;
}
public static bool TryCreateArcFromThreePoints(
Point2 start,
Point2 pointOnArc,
Point2 end,
out ArcDefinition arc,
double epsilon = 1e-8)
{
if (!TryCreateCircleFromThreePoints(start, pointOnArc, end, out var circle, epsilon))
{
arc = default;
return false;
}
var startAngle = AngleDeg(circle.Center, start);
var midAngle = AngleDeg(circle.Center, pointOnArc);
var endAngle = AngleDeg(circle.Center, end);
var isClockwise = !AngleIsOnSweep(startAngle, endAngle, midAngle, isClockwise: false);
arc = new ArcDefinition(circle.Center, circle.Radius, startAngle, endAngle, isClockwise);
return true;
}
public static bool AngleIsOnSweep(double startDeg, double endDeg, double candidateDeg, bool isClockwise)
{
startDeg = NormalizeAngleDeg(startDeg);
endDeg = NormalizeAngleDeg(endDeg);
candidateDeg = NormalizeAngleDeg(candidateDeg);
if (!isClockwise)
{
var sweep = CounterClockwiseSweepDeg(startDeg, endDeg);
var candidateSweep = CounterClockwiseSweepDeg(startDeg, candidateDeg);
return candidateSweep <= sweep + GeometryConstants.Epsilon;
}
var clockwiseSweep = ClockwiseSweepDeg(startDeg, endDeg);
var candidateClockwiseSweep = ClockwiseSweepDeg(startDeg, candidateDeg);
return candidateClockwiseSweep <= clockwiseSweep + GeometryConstants.Epsilon;
}
public static double CounterClockwiseSweepDeg(double startDeg, double endDeg)
{
return NormalizeAngleDeg(endDeg - startDeg);
}
public static double ClockwiseSweepDeg(double startDeg, double endDeg)
{
return NormalizeAngleDeg(startDeg - endDeg);
}
}

View File

@@ -0,0 +1,17 @@
namespace TraceCad.Core.Geometry;
public readonly record struct Point2(double X, double Y)
{
public static Point2 Origin { get; } = new(0.0, 0.0);
public double DistanceTo(Point2 other) => (this - other).Length;
public static Point2 operator +(Point2 point, Vector2 vector) =>
new(point.X + vector.X, point.Y + vector.Y);
public static Point2 operator -(Point2 point, Vector2 vector) =>
new(point.X - vector.X, point.Y - vector.Y);
public static Vector2 operator -(Point2 left, Point2 right) =>
new(left.X - right.X, left.Y - right.Y);
}

View File

@@ -0,0 +1,25 @@
namespace TraceCad.Core.Geometry;
public readonly record struct Vector2(double X, double Y)
{
public double Length => Math.Sqrt((X * X) + (Y * Y));
public Vector2 Normalized()
{
var length = Length;
return length <= GeometryConstants.Epsilon ? new Vector2(0.0, 0.0) : new Vector2(X / length, Y / length);
}
public double Dot(Vector2 other) => (X * other.X) + (Y * other.Y);
public double Cross(Vector2 other) => (X * other.Y) - (Y * other.X);
public static Vector2 operator +(Vector2 left, Vector2 right) =>
new(left.X + right.X, left.Y + right.Y);
public static Vector2 operator -(Vector2 left, Vector2 right) =>
new(left.X - right.X, left.Y - right.Y);
public static Vector2 operator *(Vector2 vector, double scalar) =>
new(vector.X * scalar, vector.Y * scalar);
}

View File

@@ -0,0 +1,33 @@
using TraceCad.Core.Geometry;
namespace TraceCad.Core.Model;
public sealed record ArcEntity(
Guid Id,
string Layer,
Point2 Center,
double Radius,
double StartAngleDeg,
double EndAngleDeg,
bool IsClockwise) : Entity(Id, Layer)
{
public Point2 StartPoint => PointAtAngle(StartAngleDeg);
public Point2 EndPoint => PointAtAngle(EndAngleDeg);
public override IEnumerable<Point2> SnapPoints
{
get
{
yield return StartPoint;
yield return EndPoint;
yield return Center;
}
}
public Point2 PointAtAngle(double angleDeg)
{
var radians = angleDeg * Math.PI / 180.0;
return new Point2(Center.X + (Math.Cos(radians) * Radius), Center.Y + (Math.Sin(radians) * Radius));
}
}

View File

@@ -0,0 +1,18 @@
using TraceCad.Core.Geometry;
namespace TraceCad.Core.Model;
public sealed record CircleEntity(
Guid Id,
string Layer,
Point2 Center,
double Radius) : Entity(Id, Layer)
{
public override IEnumerable<Point2> SnapPoints
{
get
{
yield return Center;
}
}
}

View File

@@ -0,0 +1,8 @@
using TraceCad.Core.Geometry;
namespace TraceCad.Core.Model;
public abstract record Entity(Guid Id, string Layer)
{
public abstract IEnumerable<Point2> SnapPoints { get; }
}

View File

@@ -0,0 +1,10 @@
namespace TraceCad.Core.Model;
public sealed record Layer(string Name, bool Visible = true, bool Locked = false, bool Exportable = true)
{
public static Layer Cut { get; } = new("CUT");
public static Layer Construction { get; } = new("CONSTRUCTION", Exportable: false);
public static Layer Reference { get; } = new("REFERENCE", Exportable: false);
public static Layer Dimensions { get; } = new("DIMENSIONS", Exportable: false);
public static Layer Debug { get; } = new("DEBUG", Visible: false, Exportable: false);
}

View File

@@ -0,0 +1,21 @@
using TraceCad.Core.Geometry;
namespace TraceCad.Core.Model;
public sealed record LineEntity(
Guid Id,
string Layer,
Point2 Start,
Point2 End) : Entity(Id, Layer)
{
public double Length => Start.DistanceTo(End);
public override IEnumerable<Point2> SnapPoints
{
get
{
yield return Start;
yield return End;
}
}
}

View File

@@ -0,0 +1,14 @@
namespace TraceCad.Core.Model;
public sealed record ReferenceTransform(
double OriginX,
double OriginY,
double ScaleX,
double ScaleY,
double RotationDeg);
public sealed record ReferenceImage(
string ImagePath,
double Opacity,
bool Locked,
ReferenceTransform Transform);

View File

@@ -0,0 +1,59 @@
namespace TraceCad.Core.Model;
public sealed class SketchDocument
{
public const int CurrentVersion = 1;
public int Version { get; set; } = CurrentVersion;
public string Units { get; set; } = "mm";
public double Width { get; set; } = 210.0;
public double Height { get; set; } = 297.0;
public ReferenceImage? Reference { get; set; }
public List<Layer> Layers { get; } = new();
public List<Entity> Entities { get; } = new();
public static SketchDocument CreateDefault()
{
var document = new SketchDocument();
document.Layers.AddRange(new[]
{
Layer.Cut,
Layer.Construction,
Layer.Reference,
Layer.Dimensions,
Layer.Debug
});
return document;
}
public void AddEntity(Entity entity)
{
if (Entities.Any(existing => existing.Id == entity.Id))
{
throw new InvalidOperationException($"Entity '{entity.Id}' already exists.");
}
Entities.Add(entity);
}
public bool RemoveEntity(Guid id)
{
var index = Entities.FindIndex(entity => entity.Id == id);
if (index < 0)
{
return false;
}
Entities.RemoveAt(index);
return true;
}
public Layer? FindLayer(string name) =>
Layers.FirstOrDefault(layer => string.Equals(layer.Name, name, StringComparison.OrdinalIgnoreCase));
}

View File

@@ -0,0 +1,110 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TraceCad.Core.Geometry;
using TraceCad.Core.Model;
namespace TraceCad.Core.Serialization;
public static class SketchDocumentSerializer
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static string Serialize(SketchDocument document)
{
return JsonSerializer.Serialize(ToDto(document), Options);
}
public static SketchDocument Deserialize(string json)
{
var dto = JsonSerializer.Deserialize<DocumentDto>(json, Options)
?? throw new InvalidOperationException("Project file is empty or invalid.");
return FromDto(dto);
}
private static DocumentDto ToDto(SketchDocument document) =>
new(
document.Version,
document.Units,
new DocumentSizeDto(document.Width, document.Height),
document.Reference,
document.Layers.ToList(),
document.Entities.Select(ToDto).ToList());
private static SketchDocument FromDto(DocumentDto dto)
{
var document = new SketchDocument
{
Version = dto.Version,
Units = dto.Units,
Width = dto.Document.Width,
Height = dto.Document.Height,
Reference = dto.Reference
};
document.Layers.AddRange(dto.Layers);
document.Entities.AddRange(dto.Entities.Select(FromDto));
return document;
}
private static EntityDto ToDto(Entity entity)
{
return entity switch
{
LineEntity line => EntityDto.Line(line.Id, line.Layer, line.Start, line.End),
CircleEntity circle => EntityDto.Circle(circle.Id, circle.Layer, circle.Center, circle.Radius),
ArcEntity arc => EntityDto.Arc(arc.Id, arc.Layer, arc.Center, arc.Radius, arc.StartAngleDeg, arc.EndAngleDeg, arc.IsClockwise),
_ => throw new NotSupportedException($"Unsupported entity type '{entity.GetType().Name}'.")
};
}
private static Entity FromDto(EntityDto dto)
{
return dto.Type switch
{
"line" when dto.Start is not null && dto.End is not null =>
new LineEntity(dto.Id, dto.Layer, dto.Start.Value, dto.End.Value),
"circle" when dto.Center is not null && dto.Radius is not null =>
new CircleEntity(dto.Id, dto.Layer, dto.Center.Value, dto.Radius.Value),
"arc" when dto.Center is not null && dto.Radius is not null && dto.StartAngleDeg is not null && dto.EndAngleDeg is not null =>
new ArcEntity(dto.Id, dto.Layer, dto.Center.Value, dto.Radius.Value, dto.StartAngleDeg.Value, dto.EndAngleDeg.Value, dto.IsClockwise),
_ => throw new InvalidOperationException($"Invalid or unsupported entity record '{dto.Type}'.")
};
}
private sealed record DocumentDto(
int Version,
string Units,
DocumentSizeDto Document,
ReferenceImage? Reference,
List<Layer> Layers,
List<EntityDto> Entities);
private sealed record DocumentSizeDto(double Width, double Height);
private sealed record EntityDto(
string Type,
Guid Id,
string Layer,
Point2? Start,
Point2? End,
Point2? Center,
double? Radius,
double? StartAngleDeg,
double? EndAngleDeg,
bool IsClockwise)
{
public static EntityDto Line(Guid id, string layer, Point2 start, Point2 end) =>
new("line", id, layer, start, end, null, null, null, null, false);
public static EntityDto Circle(Guid id, string layer, Point2 center, double radius) =>
new("circle", id, layer, null, null, center, radius, null, null, false);
public static EntityDto Arc(Guid id, string layer, Point2 center, double radius, double startAngleDeg, double endAngleDeg, bool isClockwise) =>
new("arc", id, layer, null, null, center, radius, startAngleDeg, endAngleDeg, isClockwise);
}
}

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,20 @@
using TraceCad.Core.Geometry;
namespace TraceCad.Vision.Calibration;
public sealed record ReferenceMarkerDefinition(int Id, Point2 TopLeftMm, double SizeMm)
{
public Point2 TopRightMm => new(TopLeftMm.X + SizeMm, TopLeftMm.Y);
public Point2 BottomRightMm => new(TopLeftMm.X + SizeMm, TopLeftMm.Y + SizeMm);
public Point2 BottomLeftMm => new(TopLeftMm.X, TopLeftMm.Y + SizeMm);
public IReadOnlyList<Point2> CornersMm => new[]
{
TopLeftMm,
TopRightMm,
BottomRightMm,
BottomLeftMm
};
}

View File

@@ -0,0 +1,39 @@
using TraceCad.Core.Geometry;
namespace TraceCad.Vision.Calibration;
public sealed record ReferenceSheetTemplate(
string Id,
double WidthMm,
double HeightMm,
double MarkerSizeMm,
double ReferenceLineLengthMm,
Point2 CrosshairMm,
IReadOnlyList<ReferenceMarkerDefinition> Markers)
{
public static ReferenceSheetTemplate DefaultA4()
{
const double width = 297.0;
const double height = 210.0;
const double marker = 15.0;
return new ReferenceSheetTemplate(
"easytrace-a4-aruco-5x5-v1",
width,
height,
marker,
ReferenceLineLengthMm: 30.0,
CrosshairMm: new Point2(width / 2.0, height / 2.0),
Markers: new[]
{
new ReferenceMarkerDefinition(0, new Point2(29.804, 10.666), marker),
new ReferenceMarkerDefinition(1, new Point2(104.776, 10.666), marker),
new ReferenceMarkerDefinition(2, new Point2(176.221, 10.666), marker),
new ReferenceMarkerDefinition(3, new Point2(251.193, 10.666), marker),
new ReferenceMarkerDefinition(4, new Point2(29.804, 184.417), marker),
new ReferenceMarkerDefinition(5, new Point2(104.933, 184.417), marker),
new ReferenceMarkerDefinition(6, new Point2(176.221, 184.417), marker),
new ReferenceMarkerDefinition(7, new Point2(251.193, 184.417), marker)
});
}
}

View File

@@ -0,0 +1,15 @@
namespace TraceCad.Vision.Calibration;
public sealed record SheetCalibrationResult(
bool Success,
string Message,
string? CorrectedImagePath,
string? DictionaryName,
int DetectedMarkerCount,
int MatchedMarkerCount,
double SheetWidthMm,
double SheetHeightMm,
double MmPerPixel,
double ReprojectionRmsMm,
double ReprojectionMaxMm,
string CorrectionMode);

View File

@@ -0,0 +1,518 @@
using OpenCvSharp;
using OpenCvSharp.Aruco;
using TraceCad.Core.Geometry;
namespace TraceCad.Vision.Calibration;
public sealed class SheetCalibrator
{
private const double PixelsPerMillimetre = 8.0;
private const int ResidualPolynomialDegree = 3;
private static readonly PredefinedDictionaryName[] CandidateDictionaries =
{
PredefinedDictionaryName.Dict5X5_50,
PredefinedDictionaryName.Dict5X5_100,
PredefinedDictionaryName.Dict5X5_250,
PredefinedDictionaryName.Dict5X5_1000
};
public SheetCalibrationResult Calibrate(
string sourceImagePath,
string outputDirectory,
ReferenceSheetTemplate? template = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sourceImagePath);
ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory);
template ??= ReferenceSheetTemplate.DefaultA4();
Directory.CreateDirectory(outputDirectory);
using var source = Cv2.ImRead(sourceImagePath, ImreadModes.Color);
if (source.Empty())
{
return Failure("Could not load calibration image.", template);
}
var detection = DetectBestDictionary(source, template);
if (detection is null)
{
return Failure("No matching 5x5 ArUco markers with IDs 0..7 were detected.", template);
}
if (detection.MatchedMarkers < 4)
{
return new SheetCalibrationResult(
false,
$"Detected {detection.MatchedMarkers} matching markers; at least 4 are required.",
null,
detection.DictionaryName,
detection.DetectedMarkers,
detection.MatchedMarkers,
template.WidthMm,
template.HeightMm,
1.0 / PixelsPerMillimetre,
0.0,
0.0,
"none");
}
using var homographyMask = new Mat();
using var homography = Cv2.FindHomography(
detection.ImagePoints,
detection.ModelPoints,
HomographyMethods.Ransac,
3.0,
homographyMask,
2000,
0.995);
if (homography.Empty())
{
return Failure("Could not compute sheet homography.", template);
}
var inlierIndices = HomographyInlierIndices(homographyMask, detection.ImagePoints.Count);
var metrics = CalculateReprojectionMetrics(
detection.ImagePoints,
detection.ModelPoints,
homography,
inlierIndices);
var outputSize = new Size(
(int)Math.Round(template.WidthMm * PixelsPerMillimetre),
(int)Math.Round(template.HeightMm * PixelsPerMillimetre));
using var corrected = CorrectImage(source, homography, detection, outputSize, inlierIndices);
var outputPath = Path.Combine(
outputDirectory,
$"{Path.GetFileNameWithoutExtension(sourceImagePath)}.calibrated.png");
Cv2.ImWrite(outputPath, corrected);
return new SheetCalibrationResult(
true,
$"Calibrated using {detection.MatchedMarkers} markers ({detection.DictionaryName}); marker RMS {metrics.RmsMm:0.###} mm, max {metrics.MaxMm:0.###} mm.",
outputPath,
detection.DictionaryName,
detection.DetectedMarkers,
detection.MatchedMarkers,
template.WidthMm,
template.HeightMm,
1.0 / PixelsPerMillimetre,
metrics.RmsMm,
metrics.MaxMm,
"perspective + marker residual warp");
}
private static Detection? DetectBestDictionary(Mat source, ReferenceSheetTemplate template)
{
Detection? best = null;
foreach (var dictionaryName in CandidateDictionaries)
{
var dictionary = CvAruco.GetPredefinedDictionary(dictionaryName);
var parameters = new DetectorParameters
{
CornerRefinementMethod = CornerRefineMethod.Subpix,
CornerRefinementWinSize = 7,
CornerRefinementMaxIterations = 80,
CornerRefinementMinAccuracy = 0.005,
UseAruco3Detection = true
};
CvAruco.DetectMarkers(
source,
dictionary,
out var corners,
out var ids,
parameters,
out _);
if (ids.Length == 0)
{
continue;
}
var detection = BuildDetection(dictionaryName.ToString(), ids, corners, template);
if (detection.MatchedMarkers > (best?.MatchedMarkers ?? -1))
{
best = detection;
}
}
return best;
}
private static Detection BuildDetection(
string dictionaryName,
int[] ids,
Point2f[][] corners,
ReferenceSheetTemplate template)
{
var markerById = template.Markers.ToDictionary(marker => marker.Id);
var imagePoints = new List<Point2d>();
var modelPoints = new List<Point2d>();
var matchedMarkerIds = new HashSet<int>();
for (var i = 0; i < ids.Length; i++)
{
if (!markerById.TryGetValue(ids[i], out var marker))
{
continue;
}
matchedMarkerIds.Add(ids[i]);
var imageCorners = corners[i];
var modelCorners = marker.CornersMm;
for (var cornerIndex = 0; cornerIndex < 4; cornerIndex++)
{
imagePoints.Add(ToDoublePoint(imageCorners[cornerIndex]));
modelPoints.Add(ToPixelPoint(modelCorners[cornerIndex]));
}
}
return new Detection(
dictionaryName,
ids.Length,
matchedMarkerIds.Count,
imagePoints,
modelPoints);
}
private static Point2d ToPixelPoint(Point2 point) =>
new(
point.X * PixelsPerMillimetre,
point.Y * PixelsPerMillimetre);
private static Point2d ToDoublePoint(Point2f point) =>
new(point.X, point.Y);
private static SheetCalibrationResult Failure(string message, ReferenceSheetTemplate template) =>
new(
false,
message,
null,
null,
0,
0,
template.WidthMm,
template.HeightMm,
1.0 / PixelsPerMillimetre,
0.0,
0.0,
"none");
private static Mat CorrectImage(
Mat source,
Mat homography,
Detection detection,
Size outputSize,
IReadOnlyList<int> inlierIndices)
{
if (inlierIndices.Count < PolynomialTermCount(ResidualPolynomialDegree))
{
var perspectiveOnly = new Mat();
Cv2.WarpPerspective(source, perspectiveOnly, homography, outputSize);
return perspectiveOnly;
}
using var inverseHomography = homography.Inv();
var inverse = ReadHomography(inverseHomography);
var modelPoints = inlierIndices.Select(index => detection.ModelPoints[index]).ToArray();
var residualX = new double[modelPoints.Length];
var residualY = new double[modelPoints.Length];
for (var i = 0; i < modelPoints.Length; i++)
{
var index = inlierIndices[i];
var projected = ApplyHomography(inverse, detection.ModelPoints[index]);
residualX[i] = detection.ImagePoints[index].X - projected.X;
residualY[i] = detection.ImagePoints[index].Y - projected.Y;
}
var xSurface = PolynomialSurface.Fit(modelPoints, residualX, outputSize, ResidualPolynomialDegree);
var ySurface = PolynomialSurface.Fit(modelPoints, residualY, outputSize, ResidualPolynomialDegree);
if (xSurface is null || ySurface is null)
{
var perspectiveOnly = new Mat();
Cv2.WarpPerspective(source, perspectiveOnly, homography, outputSize);
return perspectiveOnly;
}
using var mapX = new Mat(outputSize.Height, outputSize.Width, MatType.CV_32FC1);
using var mapY = new Mat(outputSize.Height, outputSize.Width, MatType.CV_32FC1);
for (var y = 0; y < outputSize.Height; y++)
{
for (var x = 0; x < outputSize.Width; x++)
{
var modelPoint = new Point2d(x, y);
var projected = ApplyHomography(inverse, modelPoint);
mapX.Set(y, x, (float)(projected.X + xSurface.Evaluate(modelPoint)));
mapY.Set(y, x, (float)(projected.Y + ySurface.Evaluate(modelPoint)));
}
}
var corrected = new Mat();
Cv2.Remap(
source,
corrected,
mapX,
mapY,
InterpolationFlags.Linear,
BorderTypes.Constant,
Scalar.White);
return corrected;
}
private static IReadOnlyList<int> HomographyInlierIndices(Mat mask, int pointCount)
{
if (mask.Empty())
{
return Enumerable.Range(0, pointCount).ToArray();
}
var indices = new List<int>();
for (var i = 0; i < pointCount; i++)
{
if (mask.Get<byte>(i, 0) != 0)
{
indices.Add(i);
}
}
return indices.Count == 0 ? Enumerable.Range(0, pointCount).ToArray() : indices;
}
private static ReprojectionMetrics CalculateReprojectionMetrics(
IReadOnlyList<Point2d> imagePoints,
IReadOnlyList<Point2d> modelPoints,
Mat homography,
IReadOnlyList<int> pointIndices)
{
var h = ReadHomography(homography);
var sumSquaredMm = 0.0;
var maxMm = 0.0;
foreach (var index in pointIndices)
{
var projected = ApplyHomography(h, imagePoints[index]);
var errorMm = Distance(projected, modelPoints[index]) / PixelsPerMillimetre;
sumSquaredMm += errorMm * errorMm;
maxMm = Math.Max(maxMm, errorMm);
}
var rmsMm = pointIndices.Count == 0 ? 0.0 : Math.Sqrt(sumSquaredMm / pointIndices.Count);
return new ReprojectionMetrics(rmsMm, maxMm);
}
private static double[] ReadHomography(Mat homography) =>
new[]
{
homography.At<double>(0, 0),
homography.At<double>(0, 1),
homography.At<double>(0, 2),
homography.At<double>(1, 0),
homography.At<double>(1, 1),
homography.At<double>(1, 2),
homography.At<double>(2, 0),
homography.At<double>(2, 1),
homography.At<double>(2, 2)
};
private static Point2d ApplyHomography(IReadOnlyList<double> h, Point2d point)
{
var denominator = (h[6] * point.X) + (h[7] * point.Y) + h[8];
if (Math.Abs(denominator) < 1e-12)
{
return point;
}
return new Point2d(
((h[0] * point.X) + (h[1] * point.Y) + h[2]) / denominator,
((h[3] * point.X) + (h[4] * point.Y) + h[5]) / denominator);
}
private static double Distance(Point2d a, Point2d b)
{
var dx = a.X - b.X;
var dy = a.Y - b.Y;
return Math.Sqrt((dx * dx) + (dy * dy));
}
private static int PolynomialTermCount(int degree) =>
degree switch
{
0 => 1,
1 => 3,
2 => 6,
_ => 10
};
private sealed record Detection(
string DictionaryName,
int DetectedMarkers,
int MatchedMarkers,
IReadOnlyList<Point2d> ImagePoints,
IReadOnlyList<Point2d> ModelPoints);
private sealed record ReprojectionMetrics(double RmsMm, double MaxMm);
private sealed class PolynomialSurface
{
private readonly double[] _coefficients;
private readonly Size _size;
private readonly int _degree;
private PolynomialSurface(double[] coefficients, Size size, int degree)
{
_coefficients = coefficients;
_size = size;
_degree = degree;
}
public static PolynomialSurface? Fit(
IReadOnlyList<Point2d> points,
IReadOnlyList<double> values,
Size size,
int requestedDegree)
{
var degree = requestedDegree;
while (degree > 0 && points.Count < PolynomialTermCount(degree))
{
degree--;
}
var termCount = PolynomialTermCount(degree);
var normal = new double[termCount, termCount];
var rhs = new double[termCount];
for (var i = 0; i < points.Count; i++)
{
var basis = Basis(points[i], size, degree);
for (var row = 0; row < termCount; row++)
{
rhs[row] += basis[row] * values[i];
for (var column = 0; column < termCount; column++)
{
normal[row, column] += basis[row] * basis[column];
}
}
}
for (var i = 0; i < termCount; i++)
{
normal[i, i] += 1e-10;
}
var coefficients = Solve(normal, rhs);
return coefficients is null ? null : new PolynomialSurface(coefficients, size, degree);
}
public double Evaluate(Point2d point)
{
var basis = Basis(point, _size, _degree);
var value = 0.0;
for (var i = 0; i < _coefficients.Length; i++)
{
value += _coefficients[i] * basis[i];
}
return value;
}
private static double[] Basis(Point2d point, Size size, int degree)
{
var x = ((point.X / Math.Max(1.0, size.Width - 1.0)) * 2.0) - 1.0;
var y = ((point.Y / Math.Max(1.0, size.Height - 1.0)) * 2.0) - 1.0;
return degree switch
{
0 => new[] { 1.0 },
1 => new[] { 1.0, x, y },
2 => new[] { 1.0, x, y, x * x, x * y, y * y },
_ => new[]
{
1.0,
x,
y,
x * x,
x * y,
y * y,
x * x * x,
x * x * y,
x * y * y,
y * y * y
}
};
}
private static double[]? Solve(double[,] matrix, double[] rhs)
{
var n = rhs.Length;
var augmented = new double[n, n + 1];
for (var row = 0; row < n; row++)
{
for (var column = 0; column < n; column++)
{
augmented[row, column] = matrix[row, column];
}
augmented[row, n] = rhs[row];
}
for (var pivot = 0; pivot < n; pivot++)
{
var bestRow = pivot;
var bestValue = Math.Abs(augmented[pivot, pivot]);
for (var row = pivot + 1; row < n; row++)
{
var value = Math.Abs(augmented[row, pivot]);
if (value > bestValue)
{
bestRow = row;
bestValue = value;
}
}
if (bestValue < 1e-12)
{
return null;
}
if (bestRow != pivot)
{
for (var column = pivot; column <= n; column++)
{
(augmented[pivot, column], augmented[bestRow, column]) =
(augmented[bestRow, column], augmented[pivot, column]);
}
}
var pivotValue = augmented[pivot, pivot];
for (var column = pivot; column <= n; column++)
{
augmented[pivot, column] /= pivotValue;
}
for (var row = 0; row < n; row++)
{
if (row == pivot)
{
continue;
}
var factor = augmented[row, pivot];
for (var column = pivot; column <= n; column++)
{
augmented[row, column] -= factor * augmented[pivot, column];
}
}
}
var solution = new double[n];
for (var row = 0; row < n; row++)
{
solution[row] = augmented[row, n];
}
return solution;
}
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TraceCad.Core\TraceCad.Core.csproj" />
<PackageReference Include="OpenCvSharp4" Version="4.10.0.20241108" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.10.0.20241108" />
</ItemGroup>
</Project>