diff --git a/samples/AvalonDraw/MainWindow.axaml b/samples/AvalonDraw/MainWindow.axaml index c6b79b7e34..d2405f1132 100644 --- a/samples/AvalonDraw/MainWindow.axaml +++ b/samples/AvalonDraw/MainWindow.axaml @@ -190,6 +190,10 @@ + + + diff --git a/samples/AvalonDraw/MainWindow.axaml.cs b/samples/AvalonDraw/MainWindow.axaml.cs index a05ac901bc..69290f5c5b 100644 --- a/samples/AvalonDraw/MainWindow.axaml.cs +++ b/samples/AvalonDraw/MainWindow.axaml.cs @@ -55,6 +55,7 @@ private struct DragInfo private readonly PropertiesService _propertiesService = new(); private readonly LayerService _layerService = new(); private readonly PatternService _patternService = new(); + private readonly BrushService _brushService = new(); public ObservableCollection Properties => _propertiesService.Properties; public ObservableCollection FilteredProperties => _propertiesService.FilteredProperties; private ObservableCollection Nodes { get; } = new(); @@ -62,12 +63,15 @@ private struct DragInfo public ObservableCollection Artboards { get; } = new(); public ObservableCollection Layers => _layerService.Layers; public ObservableCollection Patterns => _patternService.Patterns; + public ObservableCollection BrushStyles => _brushService.Brushes; private ArtboardInfo? _selectedArtboard; private LayerService.LayerEntry? _selectedLayer; private PatternService.PatternEntry? _selectedPattern; + private BrushService.BrushEntry? _selectedBrush; private ListBox? _artboardList; private TreeView? _layerTree; private ListBox? _swatchList; + private ListBox? _brushList; private readonly HashSet _expandedIds = new(); private HashSet _filterBackup = new(); @@ -293,6 +297,24 @@ public MainWindow() }; return btn; } + if (entry is StrokeProfileEntry spEntry) + { + var btn = new Button { Content = entry.Value ?? "Edit", VerticalAlignment = VerticalAlignment.Center }; + btn.Click += async (_, _) => + { + var dlg = new StrokeProfileEditorWindow(spEntry.Points); + var result = await dlg.ShowDialog(this); + if (result) + { + spEntry.Points.Clear(); + foreach (var p in dlg.Result) + spEntry.Points.Add(p); + spEntry.UpdateValue(); + spEntry.NotifyChanged(); + } + }; + return btn; + } var tb = new TextBox { VerticalContentAlignment = VerticalAlignment.Center }; tb[!TextBox.TextProperty] = new Binding("Value") { Mode = BindingMode.TwoWay }; return tb; @@ -324,6 +346,7 @@ public MainWindow() _artboardList = this.FindControl("ArtboardList"); _layerTree = this.FindControl("LayerTree"); _swatchList = this.FindControl("SwatchList"); + _brushList = this.FindControl("BrushList"); _wireframeEnabled = false; _filtersDisabled = false; _snapToGrid = false; @@ -1501,6 +1524,7 @@ private void BuildTree() UpdateArtboards(); UpdateLayers(); UpdatePatterns(); + UpdateBrushes(); } private void ApplyPropertyFilter() @@ -1561,6 +1585,17 @@ private void UpdatePatterns() } } + private void UpdateBrushes() + { + if (BrushStyles.Count > 0) + { + _selectedBrush = BrushStyles[0]; + _toolService.CurrentStrokeWidth = (float)_selectedBrush.Profile.Points.First().Width; + if (_brushList is { }) + _brushList.SelectedIndex = 0; + } + } + private SvgNode CreateNode(SvgElement element, SvgNode? parent = null) { @@ -2213,6 +2248,15 @@ private void SwatchList_OnSelectionChanged(object? sender, SelectionChangedEvent } } + private void BrushList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (e.AddedItems.Count > 0 && e.AddedItems[0] is BrushService.BrushEntry info) + { + _selectedBrush = info; + _toolService.CurrentStrokeWidth = (float)info.Profile.Points.First().Width; + } + } + private void SelectToolButton_Click(object? sender, RoutedEventArgs e) { if (_pathService.IsEditing) diff --git a/samples/AvalonDraw/Services/BrushService.cs b/samples/AvalonDraw/Services/BrushService.cs new file mode 100644 index 0000000000..a97e5d1644 --- /dev/null +++ b/samples/AvalonDraw/Services/BrushService.cs @@ -0,0 +1,31 @@ +using System.Collections.ObjectModel; + +namespace AvalonDraw.Services; + +public class BrushService +{ + public class BrushEntry + { + public StrokeProfile Profile { get; } + public string Name { get; } + + public BrushEntry(string name, StrokeProfile profile) + { + Name = name; + Profile = profile; + } + + public override string ToString() => Name; + } + + public ObservableCollection Brushes { get; } = new(); + public BrushEntry? SelectedBrush { get; set; } + + public BrushService() + { + var def = new StrokeProfile(); + Brushes.Add(new BrushEntry("Default", def)); + SelectedBrush = Brushes[0]; + } +} + diff --git a/samples/AvalonDraw/Services/PropertiesService.cs b/samples/AvalonDraw/Services/PropertiesService.cs index 3b0052d4b4..e26272ee5c 100644 --- a/samples/AvalonDraw/Services/PropertiesService.cs +++ b/samples/AvalonDraw/Services/PropertiesService.cs @@ -92,6 +92,14 @@ public void LoadProperties(SvgElement element) Properties.Add(gEntry); } + if (element is SvgVisualElement vis && + vis.CustomAttributes.TryGetValue("stroke-profile", out var prof)) + { + var sEntry = new StrokeProfileEntry(prof); + sEntry.PropertyChanged += OnEntryChanged; + Properties.Add(sEntry); + } + LoadAppearanceLayers(element); ApplyFilter(_filter); diff --git a/samples/AvalonDraw/Services/RenderingService.cs b/samples/AvalonDraw/Services/RenderingService.cs index c3a4c417f0..13d2e6f1dc 100644 --- a/samples/AvalonDraw/Services/RenderingService.cs +++ b/samples/AvalonDraw/Services/RenderingService.cs @@ -1,10 +1,13 @@ using System.Collections.Generic; -using SK = SkiaSharp; +using System.Linq; +using AvalonDraw; +using AvalonDraw.Services; +using Svg; using Svg.Model.Drawables; using Svg.Pathing; -using Shim = ShimSkiaSharp; -using AvalonDraw; using static AvalonDraw.Services.SelectionService; +using Shim = ShimSkiaSharp; +using SK = SkiaSharp; // Provides rendering helpers for editor overlays @@ -21,6 +24,24 @@ public class RenderingService private const float HandleSize = 10f; + private static void DrawProfile(SK.SKCanvas canvas, SK.SKPath path, StrokeProfile profile, SK.SKPaint paint) + { + using var measure = new SK.SKPathMeasure(path, false); + var length = measure.Length; + var pts = profile.Points.OrderBy(p => p.Offset).ToList(); + for (var i = 1; i < pts.Count; i++) + { + using var seg = new SK.SKPath(); + var start = (float)pts[i - 1].Offset * length; + var end = (float)pts[i].Offset * length; + if (measure.GetSegment(start, end, seg, true)) + { + paint.StrokeWidth = (float)((pts[i - 1].Width + pts[i].Width) / 2.0); + canvas.DrawPath(seg, paint); + } + } + } + public RenderingService(PathService pathService, ToolService toolService) { _pathService = pathService; @@ -158,6 +179,18 @@ void DrawLayer(LayerService.LayerEntry info) canvas.DrawCircle(info.RotHandle, hs, paint); } + if (selectedDrawable.Element is SvgVisualElement vis && + vis.CustomAttributes.TryGetValue("stroke-profile", out var prof)) + { + var profile = StrokeProfile.Parse(prof); + var skPath = PathService.ElementToPath(vis); + if (skPath is { }) + { + using var spaint = new SK.SKPaint { IsAntialias = true, Style = SK.SKPaintStyle.Stroke, Color = SK.SKColors.Black }; + DrawProfile(canvas, skPath, profile, spaint); + } + } + if (_pathService.IsEditing && _pathService.EditDrawable == selectedDrawable) { using var segPaint = new SK.SKPaint diff --git a/samples/AvalonDraw/Services/StrokeProfile.cs b/samples/AvalonDraw/Services/StrokeProfile.cs new file mode 100644 index 0000000000..ab4f3fb5f6 --- /dev/null +++ b/samples/AvalonDraw/Services/StrokeProfile.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; + +namespace AvalonDraw.Services; + +public class StrokePointInfo +{ + public double Offset { get; set; } + public double Width { get; set; } +} + +public class StrokeProfile +{ + public ObservableCollection Points { get; } + + public StrokeProfile() + { + Points = new ObservableCollection + { + new() { Offset = 0.0, Width = 1.0 }, + new() { Offset = 1.0, Width = 1.0 } + }; + } + + public static StrokeProfile Parse(string text) + { + var profile = new StrokeProfile(); + profile.Points.Clear(); + foreach (var part in text.Split(';')) + { + var items = part.Split(','); + if (items.Length != 2) + continue; + if (double.TryParse(items[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var o) && + double.TryParse(items[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var w)) + { + profile.Points.Add(new StrokePointInfo { Offset = o, Width = w }); + } + } + if (profile.Points.Count == 0) + { + profile.Points.Add(new StrokePointInfo { Offset = 0.0, Width = 1.0 }); + profile.Points.Add(new StrokePointInfo { Offset = 1.0, Width = 1.0 }); + } + return profile; + } + + public override string ToString() + { + var parts = new List(Points.Count); + parts.AddRange(Points.Select(p => $"{p.Offset.ToString(CultureInfo.InvariantCulture)},{p.Width.ToString(CultureInfo.InvariantCulture)}")); + return string.Join(";", parts); + } +} + diff --git a/samples/AvalonDraw/Services/StrokeProfileEntry.cs b/samples/AvalonDraw/Services/StrokeProfileEntry.cs new file mode 100644 index 0000000000..5c199f3b91 --- /dev/null +++ b/samples/AvalonDraw/Services/StrokeProfileEntry.cs @@ -0,0 +1,40 @@ +using System.Collections.ObjectModel; +using System.Linq; +using Svg; + +namespace AvalonDraw.Services; + +public class StrokeProfileEntry : PropertyEntry +{ + public ObservableCollection Points { get; } + + public StrokeProfileEntry(string text) + : base("StrokeProfile", text, (_, __) => { }) + { + Points = StrokeProfile.Parse(text).Points; + } + + public override void Apply(object target) + { + if (target is SvgVisualElement element) + { + element.CustomAttributes["stroke-profile"] = ToString(); + } + } + + public void UpdateValue() + { + Value = ToString(); + } + + public override string ToString() + { + var profile = new StrokeProfile(); + profile.Points.Clear(); + foreach (var p in Points) + profile.Points.Add(new StrokePointInfo { Offset = p.Offset, Width = p.Width }); + return profile.ToString(); + } +} + + diff --git a/samples/AvalonDraw/Services/ToolService.cs b/samples/AvalonDraw/Services/ToolService.cs index 2ea66e1bcf..dcc9e0f429 100644 --- a/samples/AvalonDraw/Services/ToolService.cs +++ b/samples/AvalonDraw/Services/ToolService.cs @@ -34,6 +34,8 @@ public enum Tool public Tool CurrentTool { get; private set; } = Tool.Select; + public float CurrentStrokeWidth { get; set; } = 1f; + public event Action? ToolChanged; public void SetTool(Tool tool) @@ -60,7 +62,7 @@ public void SetTool(Tool tool) EndX = new SvgUnit(SvgUnitType.User, start.X), EndY = new SvgUnit(SvgUnitType.User, start.Y), Stroke = new SvgColourServer(System.Drawing.Color.Black), - StrokeWidth = new SvgUnit(1f) + StrokeWidth = new SvgUnit(CurrentStrokeWidth) }, Tool.Rect => new SvgRectangle { @@ -90,7 +92,7 @@ public void SetTool(Tool tool) new SvgUnit(SvgUnitType.User, start.X), new SvgUnit(SvgUnitType.User, start.Y) }, Stroke = new SvgColourServer(System.Drawing.Color.Black), - StrokeWidth = new SvgUnit(1f) + StrokeWidth = new SvgUnit(CurrentStrokeWidth) }, Tool.Polyline => new SvgPolyline { @@ -100,7 +102,7 @@ public void SetTool(Tool tool) new SvgUnit(SvgUnitType.User, start.X), new SvgUnit(SvgUnitType.User, start.Y) }, Stroke = new SvgColourServer(System.Drawing.Color.Black), - StrokeWidth = new SvgUnit(1f) + StrokeWidth = new SvgUnit(CurrentStrokeWidth) }, Tool.Text => new SvgText { @@ -144,12 +146,12 @@ public void SetTool(Tool tool) }; } - private static SvgPath CreatePath(ShimSkiaSharp.SKPoint start, Tool tool) + private SvgPath CreatePath(ShimSkiaSharp.SKPoint start, Tool tool) { var path = new SvgPath { Stroke = new SvgColourServer(System.Drawing.Color.Black), - StrokeWidth = new SvgUnit(1f) + StrokeWidth = new SvgUnit(CurrentStrokeWidth) }; var list = new SvgPathSegmentList { diff --git a/samples/AvalonDraw/StrokeProfileEditorWindow.axaml b/samples/AvalonDraw/StrokeProfileEditorWindow.axaml new file mode 100644 index 0000000000..65668680f3 --- /dev/null +++ b/samples/AvalonDraw/StrokeProfileEditorWindow.axaml @@ -0,0 +1,20 @@ + + + + + + + + + +