Files
easyTrace/src/TraceCad.App/MainWindow.cs
2026-05-02 01:01:15 +02:00

740 lines
26 KiB
C#

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;
}
}
}