Files
kniip/src/Kniip/Editor/Annotations/LineAnnotation.cs
T
rogierandCursor 67c98c7c0a 1.1.4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 16:36:28 +01:00

290 lines
8.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Media;
namespace Kniip.Editor.Annotations;
/// <summary>
/// Lijn of pijl. Met buigpunten wordt het een vloeiende kromme die door alle punten loopt.
/// </summary>
public sealed class LineAnnotation : Annotation
{
private const int SamplesPerSegment = 16;
private int _dragBend = -1;
private int _pendingGhost = -1;
public LineAnnotation(bool isArrow)
{
IsArrow = isArrow;
}
public bool IsArrow { get; }
public Point Start { get; set; }
public Point End { get; set; }
/// <summary>Tussenpunten waar de kromme doorheen loopt, in volgorde van begin naar eind.</summary>
public List<Point> Bends { get; set; } = new();
public override ToolKind Kind => IsArrow ? ToolKind.Arrow : ToolKind.Line;
public override Rect Bounds
{
get
{
List<Point> points = Flatten();
var r = new Rect(points[0], points[0]);
foreach (Point p in points)
{
r.Union(p);
}
double pad = Style.Thickness / 2 + (IsArrow ? HeadLength / 2 : 0);
r.Inflate(pad, pad);
return r;
}
}
public override bool IsDegenerate => Bends.Count == 0 && (End - Start).Length < 4;
private double HeadLength => Math.Max(12, Style.Thickness * 4);
private readonly record struct Segment(Point A, Point C1, Point C2, Point B)
{
public Point At(double t)
{
double u = 1 - t;
double a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t;
return new Point(
a * A.X + b * C1.X + c * C2.X + d * B.X,
a * A.Y + b * C1.Y + c * C2.Y + d * B.Y);
}
}
private List<Point> ThroughPoints()
{
var points = new List<Point>(Bends.Count + 2) { Start };
points.AddRange(Bends);
points.Add(End);
return points;
}
/// <summary>Catmull-Rom door alle punten, omgezet naar kubische Bezier-segmenten.</summary>
private List<Segment> Segments()
{
List<Point> p = ThroughPoints();
int last = p.Count - 1;
var segments = new List<Segment>(last);
for (int i = 0; i < last; i++)
{
Point prev = p[Math.Max(i - 1, 0)];
Point next = p[Math.Min(i + 2, last)];
segments.Add(new Segment(p[i], p[i] + (p[i + 1] - prev) / 6, p[i + 1] - (next - p[i]) / 6, p[i + 1]));
}
return segments;
}
private List<Point> Flatten()
{
if (Bends.Count == 0)
{
return new List<Point> { Start, End };
}
var points = new List<Point> { Start };
foreach (Segment s in Segments())
{
for (int k = 1; k <= SamplesPerSegment; k++)
{
points.Add(s.At(k / (double)SamplesPerSegment));
}
}
return points;
}
private static double PathLength(List<Point> points)
{
double length = 0;
for (int i = 1; i < points.Count; i++)
{
length += (points[i] - points[i - 1]).Length;
}
return length;
}
private Geometry BuildGeometry(List<Segment> segments)
{
var geometry = new StreamGeometry();
using (StreamGeometryContext ctx = geometry.Open())
{
ctx.BeginFigure(segments[0].A, false, false);
foreach (Segment s in segments)
{
if (Bends.Count == 0)
{
ctx.LineTo(s.B, true, true);
}
else
{
ctx.BezierTo(s.C1, s.C2, s.B, true, true);
}
}
}
geometry.Freeze();
return geometry;
}
public override void Render(DrawingContext dc, EditorDocument document)
{
double length = PathLength(Flatten());
if (length < 1)
{
return;
}
Pen? pen = RenderHelpers.CreatePen(Style.Stroke, Style.Thickness, Style.Dash);
if (pen == null)
{
return;
}
List<Segment> segments = Segments();
if (IsArrow)
{
Segment lastSegment = segments[^1];
Vector v = lastSegment.B - lastSegment.C2;
if (v.Length < 1e-6)
{
v = lastSegment.B - lastSegment.A;
}
v.Normalize();
double headLength = Math.Min(HeadLength, length);
double halfWidth = headLength * 0.45;
Point basePoint = End - v * headLength;
var normal = new Vector(-v.Y, v.X);
Point lineEnd = basePoint + v * Math.Min(2, headLength / 4);
Vector shift = lineEnd - End;
segments[^1] = lastSegment with { C2 = lastSegment.C2 + shift, B = lineEnd };
var head = new StreamGeometry();
using (StreamGeometryContext ctx = head.Open())
{
ctx.BeginFigure(End, true, true);
ctx.LineTo(basePoint + normal * halfWidth, true, true);
ctx.LineTo(basePoint - normal * halfWidth, true, true);
}
head.Freeze();
Brush? headFill = RenderHelpers.BrushOrNull(Style.Stroke);
// Lijn en punt apart tekenen zodat de punt gevuld en niet gestippeld wordt.
RenderHelpers.DrawWithShadow(dc, BuildGeometry(segments), null, pen, Style.Shadow);
var headPen = RenderHelpers.CreatePen(Style.Stroke, Math.Max(1, Style.Thickness / 3), LineDash.Solid);
RenderHelpers.DrawWithShadow(dc, head, headFill, headPen, Style.Shadow);
return;
}
RenderHelpers.DrawWithShadow(dc, BuildGeometry(segments), null, pen, Style.Shadow);
}
public override bool HitTest(Point point, double tolerance)
{
double limit = tolerance + Style.Thickness / 2 + (IsArrow ? 4 : 0);
List<Point> points = Flatten();
for (int i = 1; i < points.Count; i++)
{
if (RenderHelpers.DistanceToSegment(point, points[i - 1], points[i]) <= limit)
{
return true;
}
}
return false;
}
public override void Move(Vector delta)
{
Start += delta;
End += delta;
for (int i = 0; i < Bends.Count; i++)
{
Bends[i] += delta;
}
}
/// <summary>Volgorde: begin, eind, buigpunten, en daarna één spook-greep midden op elk segment.</summary>
public override IReadOnlyList<Point> GetHandles()
{
var handles = new List<Point> { Start, End };
handles.AddRange(Bends);
foreach (Segment s in Segments())
{
handles.Add(s.At(0.5));
}
return handles;
}
public override bool IsGhostHandle(int index) => index >= 2 + Bends.Count;
public override void BeginHandleDrag(int index)
{
_pendingGhost = IsGhostHandle(index) ? index - 2 - Bends.Count : -1;
_dragBend = index >= 2 && _pendingGhost < 0 ? index - 2 : -1;
}
public override void MoveHandle(int index, Point point, bool constrain)
{
// Het buigpunt pas bij de eerste beweging invoegen, zodat een losse klik de lijn niet verandert.
if (_pendingGhost >= 0)
{
Bends.Insert(_pendingGhost, point);
_dragBend = _pendingGhost;
_pendingGhost = -1;
return;
}
if (_dragBend >= 0)
{
Bends[_dragBend] = point;
}
else if (index == 0)
{
Start = constrain ? RenderHelpers.SnapAngle(Bends.Count > 0 ? Bends[0] : End, point) : point;
}
else if (index == 1)
{
End = constrain ? RenderHelpers.SnapAngle(Bends.Count > 0 ? Bends[^1] : Start, point) : point;
}
}
public override bool RemoveHandle(int index)
{
int bend = index - 2;
if (bend < 0 || bend >= Bends.Count)
{
return false;
}
Bends.RemoveAt(bend);
return true;
}
public override Annotation Clone()
{
var clone = (LineAnnotation)base.Clone();
clone.Bends = new List<Point>(Bends);
return clone;
}
}