initial commit
This commit is contained in:
25
src/TraceCad.App/App.cs
Normal file
25
src/TraceCad.App/App.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
739
src/TraceCad.App/MainWindow.cs
Normal file
739
src/TraceCad.App/MainWindow.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/TraceCad.App/Program.cs
Normal file
18
src/TraceCad.App/Program.cs
Normal 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();
|
||||
}
|
||||
495
src/TraceCad.App/SketchCanvas.cs
Normal file
495
src/TraceCad.App/SketchCanvas.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
107
src/TraceCad.App/Tools/Arc3PointTool.cs
Normal file
107
src/TraceCad.App/Tools/Arc3PointTool.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/TraceCad.App/Tools/Circle3PointTool.cs
Normal file
92
src/TraceCad.App/Tools/Circle3PointTool.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/TraceCad.App/Tools/ISketchTool.cs
Normal file
24
src/TraceCad.App/Tools/ISketchTool.cs
Normal 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);
|
||||
}
|
||||
81
src/TraceCad.App/Tools/LineTool.cs
Normal file
81
src/TraceCad.App/Tools/LineTool.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/TraceCad.App/Tools/SelectTool.cs
Normal file
42
src/TraceCad.App/Tools/SelectTool.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
32
src/TraceCad.App/Tools/ToolContext.cs
Normal file
32
src/TraceCad.App/Tools/ToolContext.cs
Normal 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();
|
||||
}
|
||||
68
src/TraceCad.App/Tools/ToolDrawing.cs
Normal file
68
src/TraceCad.App/Tools/ToolDrawing.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
6
src/TraceCad.App/Tools/ToolRenderContext.cs
Normal file
6
src/TraceCad.App/Tools/ToolRenderContext.cs
Normal 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);
|
||||
20
src/TraceCad.App/TraceCad.App.csproj
Normal file
20
src/TraceCad.App/TraceCad.App.csproj
Normal 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>
|
||||
35
src/TraceCad.App/ViewTransform.cs
Normal file
35
src/TraceCad.App/ViewTransform.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/TraceCad.Core/Commands/AddEntityCommand.cs
Normal file
14
src/TraceCad.Core/Commands/AddEntityCommand.cs
Normal 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);
|
||||
}
|
||||
50
src/TraceCad.Core/Commands/CommandManager.cs
Normal file
50
src/TraceCad.Core/Commands/CommandManager.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
14
src/TraceCad.Core/Commands/DeleteEntityCommand.cs
Normal file
14
src/TraceCad.Core/Commands/DeleteEntityCommand.cs
Normal 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);
|
||||
}
|
||||
12
src/TraceCad.Core/Commands/IDocumentCommand.cs
Normal file
12
src/TraceCad.Core/Commands/IDocumentCommand.cs
Normal 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);
|
||||
}
|
||||
8
src/TraceCad.Core/Geometry/ArcDefinition.cs
Normal file
8
src/TraceCad.Core/Geometry/ArcDefinition.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace TraceCad.Core.Geometry;
|
||||
|
||||
public readonly record struct ArcDefinition(
|
||||
Point2 Center,
|
||||
double Radius,
|
||||
double StartAngleDeg,
|
||||
double EndAngleDeg,
|
||||
bool IsClockwise);
|
||||
3
src/TraceCad.Core/Geometry/CircleDefinition.cs
Normal file
3
src/TraceCad.Core/Geometry/CircleDefinition.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace TraceCad.Core.Geometry;
|
||||
|
||||
public readonly record struct CircleDefinition(Point2 Center, double Radius);
|
||||
6
src/TraceCad.Core/Geometry/GeometryConstants.cs
Normal file
6
src/TraceCad.Core/Geometry/GeometryConstants.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace TraceCad.Core.Geometry;
|
||||
|
||||
public static class GeometryConstants
|
||||
{
|
||||
public const double Epsilon = 1e-9;
|
||||
}
|
||||
106
src/TraceCad.Core/Geometry/GeometryHelpers.cs
Normal file
106
src/TraceCad.Core/Geometry/GeometryHelpers.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/TraceCad.Core/Geometry/Point2.cs
Normal file
17
src/TraceCad.Core/Geometry/Point2.cs
Normal 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);
|
||||
}
|
||||
25
src/TraceCad.Core/Geometry/Vector2.cs
Normal file
25
src/TraceCad.Core/Geometry/Vector2.cs
Normal 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);
|
||||
}
|
||||
33
src/TraceCad.Core/Model/ArcEntity.cs
Normal file
33
src/TraceCad.Core/Model/ArcEntity.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
18
src/TraceCad.Core/Model/CircleEntity.cs
Normal file
18
src/TraceCad.Core/Model/CircleEntity.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/TraceCad.Core/Model/Entity.cs
Normal file
8
src/TraceCad.Core/Model/Entity.cs
Normal 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; }
|
||||
}
|
||||
10
src/TraceCad.Core/Model/Layer.cs
Normal file
10
src/TraceCad.Core/Model/Layer.cs
Normal 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);
|
||||
}
|
||||
21
src/TraceCad.Core/Model/LineEntity.cs
Normal file
21
src/TraceCad.Core/Model/LineEntity.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/TraceCad.Core/Model/ReferenceImage.cs
Normal file
14
src/TraceCad.Core/Model/ReferenceImage.cs
Normal 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);
|
||||
59
src/TraceCad.Core/Model/SketchDocument.cs
Normal file
59
src/TraceCad.Core/Model/SketchDocument.cs
Normal 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));
|
||||
}
|
||||
110
src/TraceCad.Core/Serialization/SketchDocumentSerializer.cs
Normal file
110
src/TraceCad.Core/Serialization/SketchDocumentSerializer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
7
src/TraceCad.Core/TraceCad.Core.csproj
Normal file
7
src/TraceCad.Core/TraceCad.Core.csproj
Normal file
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
20
src/TraceCad.Vision/Calibration/ReferenceMarkerDefinition.cs
Normal file
20
src/TraceCad.Vision/Calibration/ReferenceMarkerDefinition.cs
Normal 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
|
||||
};
|
||||
}
|
||||
39
src/TraceCad.Vision/Calibration/ReferenceSheetTemplate.cs
Normal file
39
src/TraceCad.Vision/Calibration/ReferenceSheetTemplate.cs
Normal 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)
|
||||
});
|
||||
}
|
||||
}
|
||||
15
src/TraceCad.Vision/Calibration/SheetCalibrationResult.cs
Normal file
15
src/TraceCad.Vision/Calibration/SheetCalibrationResult.cs
Normal 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);
|
||||
518
src/TraceCad.Vision/Calibration/SheetCalibrator.cs
Normal file
518
src/TraceCad.Vision/Calibration/SheetCalibrator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/TraceCad.Vision/TraceCad.Vision.csproj
Normal file
12
src/TraceCad.Vision/TraceCad.Vision.csproj
Normal 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>
|
||||
Reference in New Issue
Block a user