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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AvalonDraw/StrokeProfileEditorWindow.axaml.cs b/samples/AvalonDraw/StrokeProfileEditorWindow.axaml.cs
new file mode 100644
index 0000000000..4bd400c923
--- /dev/null
+++ b/samples/AvalonDraw/StrokeProfileEditorWindow.axaml.cs
@@ -0,0 +1,53 @@
+using System.Collections.ObjectModel;
+using System.Linq;
+using AvalonDraw.Services;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+
+namespace AvalonDraw;
+
+public partial class StrokeProfileEditorWindow : Window
+{
+ private readonly DataGrid _grid;
+ private readonly ObservableCollection _points;
+
+ public StrokeProfileEditorWindow(ObservableCollection points)
+ {
+ InitializeComponent();
+ _grid = this.FindControl("PointsGrid");
+ _points = new ObservableCollection(points.Select(p => new StrokePointInfo { Offset = p.Offset, Width = p.Width }));
+ _grid.ItemsSource = _points;
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public ObservableCollection Result { get; private set; } = new();
+
+ private void AddButton_OnClick(object? sender, RoutedEventArgs e)
+ {
+ _points.Add(new StrokePointInfo { Offset = 0.0, Width = 1.0 });
+ }
+
+ private void RemoveButton_OnClick(object? sender, RoutedEventArgs e)
+ {
+ if (_grid.SelectedItem is StrokePointInfo info)
+ _points.Remove(info);
+ }
+
+ private void OkButton_OnClick(object? sender, RoutedEventArgs e)
+ {
+ Result = new ObservableCollection(_points.Select(p => new StrokePointInfo { Offset = p.Offset, Width = p.Width }));
+ Close(true);
+ }
+
+ private void CancelButton_OnClick(object? sender, RoutedEventArgs e)
+ {
+ Close(false);
+ }
+}
+