目標
下に示すような、始点と終点で太さが異なる直線を描画したい.
標準ライブラリでできること
WPF では太さの変わる直線を直接指定することはできないが、描画にあたって必要なことは一通り用意されている.
一定の太さを持つ直線の描画
標準の機能では一定の太さの直線、ないし直線群を描画することができる.
(Polyline の和訳が一般にどうなるべきかわからないが、この記事では直線群としておく)
<Canvas>
<!-- 直線 -->
<Path Stroke="AliceBlue" StrokeThickness="5">
<Path.Data>
<LineGeometry StartPoint="50,50" EndPoint="350,100"/>
</Path.Data>
</Path>
<Path Stroke="Cyan" StrokeThickness="5">
<Path.Data>
<PathGeometry>
<PathGeometry.Figures>
<PathFigure StartPoint="50,150">
<LineSegment Point="350,200" />
</PathFigure>
</PathGeometry.Figures>
</PathGeometry>
</Path.Data>
</Path>
<Line Stroke="Magenta" StrokeThickness="5" X1="50" Y1="250" X2="350" Y2="300" />
<!-- 直線群 -->
<Path Stroke="AliceBlue" StrokeThickness="5">
<Path.Data>
<PathGeometry>
<PathGeometry.Figures>
<PathFigure StartPoint="450,50">
<LineSegment Point="750,75" />
<LineSegment Point="450,100" />
<LineSegment Point="750,125" />
</PathFigure>
</PathGeometry.Figures>
</PathGeometry>
</Path.Data>
</Path>
<Path Stroke="Cyan" StrokeThickness="5">
<Path.Data>
<PathGeometry>
<PathGeometry.Figures>
<PathFigure StartPoint="450,150">
<PolyLineSegment>
<PolyLineSegment.Points>
<Point X="750" Y="175" />
<Point X="450" Y="200" />
<Point X="750" Y="225" />
</PolyLineSegment.Points>
</PolyLineSegment>
</PathFigure>
</PathGeometry.Figures>
</PathGeometry>
</Path.Data>
</Path>
<Polyline Stroke="Magenta" StrokeThickness="5" Points="450,250 750,275 450,300 750,325" />
</Canvas>
領域の塗りつぶし
標準の機能では System.Windows.Shapes.Path
で指定した領域を塗りつぶすことができる.
パスには直線、円弧、ベジェ曲線が用意されている.
<Canvas>
<!-- 左側 -->
<Path Stroke="AliceBlue" StrokeThickness="5" Fill="Violet">
<Path.Data>
<PathGeometry>
<PathGeometry.Figures>
<PathFigure StartPoint="50,150" IsClosed="True">
<LineSegment Point="350,200" />
<LineSegment Point="350,250" />
<LineSegment Point="200,300" />
</PathFigure>
</PathGeometry.Figures>
</PathGeometry>
</Path.Data>
</Path>
<!-- 右側 -->
<Path Stroke="AliceBlue" StrokeThickness="5" Fill="Violet">
<Path.Data>
<PathGeometry>
<PathGeometry.Figures>
<!-- IsClosed="False" としても塗りつぶしはしてくれる -->
<PathFigure StartPoint="450,150" IsClosed="False">
<ArcSegment Point="750,200" Size="300,300" />
<ArcSegment Point="750,250" Size="25,25" SweepDirection="Clockwise" />
<LineSegment Point="600,300" />
</PathFigure>
</PathGeometry.Figures>
</PathGeometry>
</Path.Data>
</Path>
</Canvas>
太さの変わる直線を一本描画する
方針
描画すべき直線の始点を s
、終点を e
とする(それぞれ Start, End の頭文字).
始点における太さを ts
、終点における太さを te
とする(Thickness の t).
直線 s-e に対する単位法線を n
とする(Normal の頭文字).
このとき、描画すべき図形は下に示した台形(p0, p1, p2, p3).
実装
public partial class SampleAPage : Page {
public SampleAPage() {
InitializeComponent();
// <Canvas x:Name="MainCanvas"/>
MainCanvas.Children.Add(DrawGradualOffsetLine(new Point(150, 200), new Point(600, 200), 100, 200, Brushes.AliceBlue));
}
public static Path DrawGradualOffsetLine(Point s, Point e, double ts, double te, Brush colour) {
Vector v = e - s;
Vector n = new Vector(-v.Y, v.X);// = new Vector(v.Y, -v.X); も可
n.Normalize();
Point p0 = s + (ts/2) * n;
Point p1 = e + (te/2) * n;
Point p2 = e - (te/2) * n;
Point p3 = s - (ts/2) * n;
return new Path() {
Fill = colour,
Data = new PathGeometry() {
Figures = {
new PathFigure() {
StartPoint = p0,
Segments = {
new PolyLineSegment() {
Points = {
p1,
p2,
p3,
},
},
},
},
},
},
};
}
}
上記コードの結果:
太さの変わる直線群を描画する
方針
例えば、次のように p0, p1, ..., p(N+1) が指定され、これを太らせることを考える.
[p0, p1]について太らせた線を f0, b0、[p1, p2]について太らせた線を f1, b1 のように名づけるとする.
このとき、太らせた線を f0, f1, ..., fN, bN, b(N-1), ..., b0 の順にたどった領域が塗りつぶすべき範囲である.
p0 における太さが ts、p(N+1) における太さが te で与えられるとき、pi における太さ ti をいい感じにすればよい.
N = 2 における例:
f0 と f1、b1 と b2 がそれぞれ交差してしまっているが、塗りつぶす段階でどうにかなるためこの記事では無視する.
実装
よく使うようならユーザーコントロールにすべきかもしれないが、とりあえず Path
を返す関数として実装する.
public partial class SampleBPage : Page {
public SampleBPage() {
InitializeComponent();
// <Canvas x:Name="MainCanvas"/>
List<Point> ps = new List<Point>() {
new Point(150, 100),
new Point(300, 325),
new Point(450, 100),
new Point(600, 325),
};
MainCanvas.Children.Add(DrawGradualOffsetPolyline(ps, 50, 200, Brushes.AliceBlue));
// 目安のためオリジナルの直線群を描画する
MainCanvas.Children.Add(new Path() {
Stroke = Brushes.DeepPink,
Data = new PathGeometry() {
Figures = {
new PathFigure() {
StartPoint = ps[0],
Segments = {
new PolyLineSegment() {
Points = new PointCollection(ps.GetRange(1,ps.Count-1)),
},
},
},
},
},
});
}
public static Path DrawGradualOffsetPolyline(List<Point> ps, double ts, double te, Brush colour) {
// 線をずらす量は太さの幅の半分なので、ここで半分にしておく
ts /= 2;
te /= 2;
// len[i] := ps[0] から ps[i] までの長さ
double[] len = new double[ps.Count];
len[0] = 0;
for (int i = 1; i < ps.Count; ++i) {
Point s = ps[i - 1];
Point e = ps[i];
Vector v = e - s;
len[i] = len[i - 1] + v.Length;
}
// t[i] := ps[i] における太さ
// これは ts から te までを線形補間すれば手に入る
double totalLength = len[ps.Count - 1];
double[] t = new double[ps.Count];
for (int i = 0; i < len.Length; ++i) {
double r = len[i] / totalLength;
t[i] = ts + r * (te - ts);
}
// 太らせた線分を計算する
(Point s, Point e)[] f = new (Point s, Point e)[ps.Count - 1];
(Point s, Point e)[] b = new (Point s, Point e)[ps.Count - 1];
for (int i = 0; i < ps.Count - 1; ++i) {
Point s = ps[i];
Point e = ps[i + 1];
Vector v = e - s;
Vector n = new Vector(-v.Y, v.X);
n.Normalize();
f[i] = (s + t[i] * n, e + t[i + 1] * n);
b[i] = (s - t[i] * n, e - t[i + 1] * n);
}
// 最終的な図形の頂点を順に収めていく
List<Point> outline = new List<Point>();
for (int i = 0; i < ps.Count - 1; ++i) {
outline.Add(f[i].s);
outline.Add(f[i].e);
}
for (int i = ps.Count - 2; i >= 0; --i) {
// 今回の作り方だと、b[i] の終点から始点に向かって線を引く.
// b[i] を作るところで b[i] = (e - ..., s - ...); とし、ここでは始点から終点へ線を引く手もある.
outline.Add(b[i].e);
outline.Add(b[i].s);
}
return new Path() {
Fill = colour,
Data = new PathGeometry() {
// 現在の計算ではパスに自己交差が生じるため、デフォルトの FillRule.EvenOdd では正しく描画されない
FillRule = FillRule.Nonzero,
Figures = {
new PathFigure() {
StartPoint = outline[0],
Segments = {
new PolyLineSegment() {
Points = new PointCollection(outline.GetRange(1,outline.Count-1)),
},
},
},
},
},
};
}
}
上記コードの結果:
直線間の結合方法
この記事の[標準ライブラリでできること]では触れなかったが、複数の直線を描画する際、直線同士の結び方は3種類用意されている.
上述の実装は Bevel
相当の結合だが、Round
、Miter
に当たる描画についても考えてみる.
<Canvas>
<!-- Bevel -->
<Path Stroke="AliceBlue" StrokeLineJoin="Bevel" StrokeThickness="20">
<Path.Data>
<PathGeometry>
<PathFigure StartPoint="50,50">
<PolyLineSegment>
<PolyLineSegment.Points>
<Point X="100" Y="400" />
<Point X="150" Y="50" />
<Point X="200" Y="400" />
</PolyLineSegment.Points>
</PolyLineSegment>
</PathFigure>
</PathGeometry>
</Path.Data>
</Path>
<!-- Round -->
<Path Stroke="Cyan" StrokeLineJoin="Round" StrokeThickness="20">
<Path.Data>
<PathGeometry>
<PathFigure StartPoint="250,50">
<PolyLineSegment>
<PolyLineSegment.Points>
<Point X="300" Y="400" />
<Point X="350" Y="50" />
<Point X="400" Y="400" />
</PolyLineSegment.Points>
</PolyLineSegment>
</PathFigure>
</PathGeometry>
</Path.Data>
</Path>
<!-- Miter -->
<Path Stroke="Magenta" StrokeLineJoin="Miter" StrokeThickness="20">
<Path.Data>
<PathGeometry>
<PathFigure StartPoint="450,50">
<PolyLineSegment>
<PolyLineSegment.Points>
<Point X="550" Y="400" />
<Point X="650" Y="50" />
<Point X="750" Y="400" />
</PolyLineSegment.Points>
</PolyLineSegment>
</PathFigure>
</PathGeometry>
</Path.Data>
</Path>
</Canvas>
StrokeLineJoin=Round
public partial class SampleCPage : Page {
public SampleCPage() {
InitializeComponent();
// <Canvas x:Name="MainCanvas"/>
List<Point> ps = new List<Point>() {
new Point(150, 100),
new Point(300, 325),
new Point(450, 100),
new Point(600, 325),
};
MainCanvas.Children.Add(DrawGradualOffsetPolylineRound(ps, 50, 200, Brushes.AliceBlue));
// 目安のためオリジナルの直線群を描画する
MainCanvas.Children.Add(new Path() {
Stroke = Brushes.DeepPink,
Data = new PathGeometry() {
Figures = {
new PathFigure() {
StartPoint = ps[0],
Segments = {
new PolyLineSegment() {
Points = new PointCollection(ps.GetRange(1,ps.Count-1)),
},
},
},
},
},
});
}
public static Path DrawGradualOffsetPolylineRound(List<Point> ps, double ts, double te, Brush colour) {
// 線をずらす量は太さの幅の半分なので、ここで半分にしておく
ts /= 2;
te /= 2;
// len[i] := ps[0] から ps[i] までの長さ
double[] len = new double[ps.Count];
len[0] = 0;
for (int i = 1; i < ps.Count; ++i) {
Point s = ps[i - 1];
Point e = ps[i];
Vector v = e - s;
len[i] = len[i - 1] + v.Length;
}
// t[i] := ps[i] における太さ
double totalLength = len[ps.Count - 1];
double[] t = new double[ps.Count];
for (int i = 0; i < len.Length; ++i) {
double r = len[i] / totalLength;
t[i] = ts + r * (te - ts);
}
(Point s, Point e)[] f = new (Point s, Point e)[ps.Count - 1];
(Point s, Point e)[] b = new (Point s, Point e)[ps.Count - 1];
for (int i = 0; i < ps.Count - 1; ++i) {
Point s = ps[i];
Point e = ps[i + 1];
Vector v = e - s;
Vector n = new Vector(-v.Y, v.X);
n.Normalize();
f[i] = (s + t[i] * n, e + t[i + 1] * n);
b[i] = (s - t[i] * n, e - t[i + 1] * n);
}
PathSegmentCollection segments = new PathSegmentCollection();
segments.Add(new LineSegment() { Point = f[0].e });
for (int i = 1; i < ps.Count - 1; ++i) {
// f[i-1].e と f[i].s をいい感じに結ぶ
// 半径 t[i] の円弧を用いればよい
segments.Add(new ArcSegment() { Point = f[i].s, Size = new Size(t[i], t[i]) });
segments.Add(new LineSegment() { Point = f[i].e });
}
segments.Add(new LineSegment() { Point = b[ps.Count - 2].e });
segments.Add(new LineSegment() { Point = b[ps.Count - 2].s });
for (int i = ps.Count - 3; i >= 0; --i) {
// b[i+1].s と b[i].e をいい感じに結ぶ
// 半径 t[i+1] の円弧を用いればよい
segments.Add(new ArcSegment() { Point = b[i].e, Size = new Size(t[i + 1], t[i + 1]) });
segments.Add(new LineSegment() { Point = b[i].s });
}
return new Path() {
Fill = colour,
Data = new PathGeometry() {
FillRule = FillRule.Nonzero,
Figures = {
new PathFigure() {
StartPoint = f[0].s,
Segments = segments,
},
},
},
};
}
}
上記コードの結果:
StrokeLineJoin=Miter
public partial class SampleDPage : Page {
public SampleDPage() {
InitializeComponent();
// <Canvas x:Name="MainCanvas"/>
List<Point> ps = new List<Point>() {
new Point(150, 100),
new Point(300, 325),
new Point(450, 100),
new Point(600, 325),
};
MainCanvas.Children.Add(DrawGradualOffsetPolylineMiter(ps, 50, 200, Brushes.AliceBlue));
// 目安のためオリジナルの直線群を描画する
MainCanvas.Children.Add(new Path() {
Stroke = Brushes.DeepPink,
Data = new PathGeometry() {
Figures = {
new PathFigure() {
StartPoint = ps[0],
Segments = {
new PolyLineSegment() {
Points = new PointCollection(ps.GetRange(1,ps.Count-1)),
},
},
},
},
},
});
}
public static Path DrawGradualOffsetPolylineMiter(List<Point> ps, double ts, double te, Brush colour) {
// 線をずらす量は太さの幅の半分なので、ここで半分にしておく
ts /= 2;
te /= 2;
// len[i] := ps[0] から ps[i] までの長さ
double[] len = new double[ps.Count];
len[0] = 0;
for (int i = 1; i < ps.Count; ++i) {
Point s = ps[i - 1];
Point e = ps[i];
Vector v = e - s;
len[i] = len[i - 1] + v.Length;
}
// t[i] := ps[i] における太さ
double totalLength = len[ps.Count - 1];
double[] t = new double[ps.Count];
for (int i = 0; i < len.Length; ++i) {
double r = len[i] / totalLength;
t[i] = ts + r * (te - ts);
}
(Point s, Point e)[] f = new (Point s, Point e)[ps.Count - 1];
(Point s, Point e)[] b = new (Point s, Point e)[ps.Count - 1];
for (int i = 0; i < ps.Count - 1; ++i) {
Point s = ps[i];
Point e = ps[i + 1];
Vector v = e - s;
Vector n = new Vector(-v.Y, v.X);
n.Normalize();
f[i] = (s + t[i] * n, e + t[i + 1] * n);
b[i] = (s - t[i] * n, e - t[i + 1] * n);
}
PathSegmentCollection segments = new PathSegmentCollection();
segments.Add(new LineSegment() { Point = f[0].e });
for (int i = 1; i < ps.Count - 1; ++i) {
// f[i-1].e と f[i].s をいい感じに結ぶ
// f[i-1] と f[i] の交点を通ればよい
Vector v0 = f[i - 1].e - f[i - 1].s;
Vector v1 = f[i].e - f[i].s;
Vector m = v1 * Vector.CrossProduct((Vector) f[i - 1].e, (Vector) f[i - 1].s) - v0 * Vector.CrossProduct((Vector) f[i].e, (Vector) f[i].s);
double d = Vector.CrossProduct(v0, v1);
segments.Add(new LineSegment() { Point = (Point) (m / d) });
segments.Add(new LineSegment() { Point = f[i].s });
segments.Add(new LineSegment() { Point = f[i].e });
}
segments.Add(new LineSegment() { Point = b[ps.Count - 2].e });
segments.Add(new LineSegment() { Point = b[ps.Count - 2].s });
for (int i = ps.Count - 3; i >= 0; --i) {
// b[i+1].s と b[i].e をいい感じに結ぶ
// b[i+1] と b[i] の交点を通ればよい
Vector v0 = b[i + 1].e - b[i + 1].s;
Vector v1 = b[i].e - b[i].s;
Vector m = v1 * Vector.CrossProduct((Vector) b[i + 1].e, (Vector) b[i + 1].s) - v0 * Vector.CrossProduct((Vector) b[i].e, (Vector) b[i].s);
double d = Vector.CrossProduct(v0, v1);
segments.Add(new LineSegment() { Point = (Point) (m / d) });
segments.Add(new LineSegment() { Point = b[i].e });
segments.Add(new LineSegment() { Point = b[i].s });
}
return new Path() {
Fill = colour,
Data = new PathGeometry() {
FillRule = FillRule.Nonzero,
Figures = {
new PathFigure() {
StartPoint = f[0].s,
Segments = segments,
},
},
},
};
}
}
上記コードの結果:
結合方法を指定できるようにする
StrokeLineJoin
をそれぞれの値で指定することに相当するコード例を示してきたが、それらをひとつにまとめる.
public static Path DrawGradualOffsetPolyline(List<Point> ps, double ts, double te, PenLineJoin join, Brush colour) {
// 線をずらす量は太さの幅の半分なので、ここで半分にしておく
ts /= 2;
te /= 2;
// len[i] := ps[0] から ps[i] までの長さ
double[] len = new double[ps.Count];
len[0] = 0;
for (int i = 1; i < ps.Count; ++i) {
Point s = ps[i - 1];
Point e = ps[i];
Vector v = e - s;
len[i] = len[i - 1] + v.Length;
}
// t[i] := ps[i] における太さ
double totalLength = len[ps.Count - 1];
double[] t = new double[ps.Count];
for (int i = 0; i < len.Length; ++i) {
double r = len[i] / totalLength;
t[i] = ts + r * (te - ts);
}
(Point s, Point e)[] f = new (Point s, Point e)[ps.Count - 1];
(Point s, Point e)[] b = new (Point s, Point e)[ps.Count - 1];
for (int i = 0; i < ps.Count - 1; ++i) {
Point s = ps[i];
Point e = ps[i + 1];
Vector v = e - s;
Vector n = new Vector(-v.Y, v.X);
n.Normalize();
f[i] = (s + t[i] * n, e + t[i + 1] * n);
b[i] = (s - t[i] * n, e - t[i + 1] * n);
}
PathSegmentCollection segments = new PathSegmentCollection();
segments.Add(new LineSegment() { Point = f[0].e });
for (int i = 1; i < ps.Count - 1; ++i) {
// f[i-1].e と f[i].s をいい感じに結ぶ
(Point s, Point e) line0 = f[i - 1];
(Point s, Point e) line1 = f[i];
JoinLines(segments, line0.s, line0.e, line1.s, line1.e, join, t[i]);
segments.Add(new LineSegment() { Point = f[i].e });
}
segments.Add(new LineSegment() { Point = b[ps.Count - 2].e });
segments.Add(new LineSegment() { Point = b[ps.Count - 2].s });
for (int i = ps.Count - 3; i >= 0; --i) {
// b[i+1].s と b[i].e をいい感じに結ぶ
(Point s, Point e) line0 = b[i + 1];
(Point s, Point e) line1 = b[i];
JoinLines(segments, line0.e, line0.s, line1.e, line1.s, join, t[i + 1]);
segments.Add(new LineSegment() { Point = line1.s });
}
return new Path() {
Fill = colour,
Data = new PathGeometry() {
FillRule = FillRule.Nonzero,
Figures = {
new PathFigure() {
StartPoint = f[0].s,
Segments = segments,
},
},
},
};
}
private static void JoinLines(PathSegmentCollection segments, Point line0s, Point line0e, Point line1s, Point line1e, PenLineJoin join, double t) {
// 線分(line0s, line0e)まで描画されている状態で、線分(line1s, line1e)を描画すべく適当な方法で接続する
switch (join) {
case PenLineJoin.Miter: {
Vector v0 = line0e - line0s;
Vector v1 = line1e - line1s;
Vector m = v1 * Vector.CrossProduct((Vector) line0e, (Vector) line0s) - v0 * Vector.CrossProduct((Vector) line1e, (Vector) line1s);
double d = Vector.CrossProduct(v0, v1);
segments.Add(new LineSegment() { Point = (Point) (m / d) });
segments.Add(new LineSegment() { Point = line1s });
} break; case PenLineJoin.Bevel: {
segments.Add(new LineSegment() { Point = line1s });
} break; case PenLineJoin.Round: {
segments.Add(new ArcSegment() { Point = line1s, Size = new Size(t, t) });
} break;
}
}
最後に
まじめなテストはしていないしする気もないが、とりあえずまあまあ期待した結果になったためよしとする.
改善点としては、次のようなものがあげられるだろう.
- 線端の処理について.
- この記事で示した例では単純に直線で結んだが、
Path
にはStrokeStartLineCap
、StrokeEndLineCap
というプロパティがあり、三角や丸にすることができる. ライブラリにまとめる気になった場合はこれも実装した方がいいかも.
- この記事で示した例では単純に直線で結んだが、
-
Miter
での結合について.-
Miter
では角度が急になると、その二直線の交点がはるか彼方に行ってしまう.Path
はこれに対処すべくStrokeMiterLimit
というプロパティを備えている. ライブラリ化する場合はそれに準じた仕様とするのが望ましかろう. - 上記とは別に(?)、急な角度で点を指定した場合(e.g.
Points="50,200 600,180 400,200 200,180"
)の描画がおかしいように思われるので、きちんとテストすべきなのかも.
-
- オフセットした直線同士の交差について.
- [太さの変わる直線群を描画する]の[方針]でも触れたが、オフセットした直線同士の交差をそのままにしている. 描画した図形の輪郭線を描きたいと思うなら対応すべき.