1
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[C#] [WPF] 直線を引く

Posted at

目標

下に示すような、始点と終点で太さが異なる直線を描画したい.

09_目標.png

標準ライブラリでできること

WPF では太さの変わる直線を直接指定することはできないが、描画にあたって必要なことは一通り用意されている.

一定の太さを持つ直線の描画

標準の機能では一定の太さの直線、ないし直線群を描画することができる.
(Polyline の和訳が一般にどうなるべきかわからないが、この記事では直線群としておく)

00_標準ライブラリで一定の太さの直線を描画.png

XAML例
<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 で指定した領域を塗りつぶすことができる.
パスには直線、円弧、ベジェ曲線が用意されている.

01_標準ライブラリで領域を塗りつぶし.png

XAML例
<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).

02_太さの変わる直線を描画する方針.png

実装

コード例
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,
                },
              },
            },
          },
        },
      },
    };
  }
}

上記コードの結果:

03_太さの変わる直線の例.png

太さの変わる直線群を描画する

方針

例えば、次のように 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 における例:

04_太さの変わる直線群を描画する方針.png

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

上記コードの結果:

05_太さの変わる直線群の例.png

直線間の結合方法

この記事の[標準ライブラリでできること]では触れなかったが、複数の直線を描画する際、直線同士の結び方は3種類用意されている.
上述の実装は Bevel 相当の結合だが、RoundMiter に当たる描画についても考えてみる.

06_直線間の結合.png

XAML例
<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,
          },
        },
      },
    };
  }
}

上記コードの結果:

07_直線間の結合_Round.png

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

上記コードの結果:

08_直線間の結合_Miter.png

結合方法を指定できるようにする

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 には StrokeStartLineCapStrokeEndLineCap というプロパティがあり、三角や丸にすることができる. ライブラリにまとめる気になった場合はこれも実装した方がいいかも.
  • Miter での結合について.
    • Miter では角度が急になると、その二直線の交点がはるか彼方に行ってしまう. Path はこれに対処すべく StrokeMiterLimit というプロパティを備えている. ライブラリ化する場合はそれに準じた仕様とするのが望ましかろう.
    • 上記とは別に(?)、急な角度で点を指定した場合(e.g. Points="50,200 600,180 400,200 200,180")の描画がおかしいように思われるので、きちんとテストすべきなのかも.
  • オフセットした直線同士の交差について.
    • [太さの変わる直線群を描画する]の[方針]でも触れたが、オフセットした直線同士の交差をそのままにしている. 描画した図形の輪郭線を描きたいと思うなら対応すべき.
1
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?