WinUI Canvasで線がずれる原因と修正方法|グリッド座標とピクセル座標の正しい扱い

WinUI(WinUI 3)でグリッド上の交点を選び、Canvas に線を引く実装は一見シンプルですが、「座標の単位」を途中で変換したまま描画すると、線が縮んだり、意図しない位置に描画されたりします。ここでは、ずれの根本原因と、最短で直す方法・再発しにくい設計までまとめて解説します。

目次

現象:交点を結んだはずの線が「思った場所」に描かれない

約 100mm × 100mm の Canvas に 20px 間隔のグリッドを用意し、ダイアログ内のチェックボックスでグリッド交点を選択して、その点同士を線で結ぶ機能を実装したとします。

ところが、たとえば (0,0) と (18,18) を選んだつもりでも、Canvas 上では「もっと内側」「短い」「縮んで見える」など、期待と違う位置に線が描かれてしまいます。

ユーザーが選ぶ座標本来の Canvas 上の位置(gridSpacing=20 の場合)ずれが起きる典型例
(0,0)(0, 0)原点付近は気づきにくい
(18,18)(360, 360)(18,18) のまま描くと (18px,18px) に描かれ、極端に縮む

原因:ピクセル座標とグリッド座標を混在させたまま描画している

問題の核はとてもシンプルで、途中で座標を「割って」グリッド番号にしたのに、描画時に「戻していない」ことです。

関係するコードのポイント

グリッド間隔は 20px。

private readonly int gridSpacing = 20; // 1 マス = 20px

チェックボックスの Tag に入っている point は「Canvas 上のピクセル座標(0, 20, 40, …)」の想定です。しかし、選択時に次のように / gridSpacing しています。

void GuidCheckBox_CheckedChanged(object sender, RoutedEventArgs e)
{
    var checkBox = sender as CheckBox;
    var point = checkBox?.Tag as PointModel;   // point.X, point.Y はキャンバス上のピクセル座標


if (point != null)
{
    // ★ここで / gridSpacing している
    var correctedPoint = new PointModel(point.X / gridSpacing, point.Y / gridSpacing);
    if (checkBox!.IsChecked == true)
    {
        _selectedPoints.Add(correctedPoint);
    }
    else
    {
        _selectedPoints.Remove(correctedPoint);
    }
}


}

この結果、_selectedPoints に入るのは、もはや「ピクセル座標」ではなく「グリッド番号(0,1,2,…)」です。

ところが描画側では、その _selectedPoints をそのまま DrawLine に渡しています。

if (_selectedPoints.Count > 1)
{
    for (int i = 0; i < _selectedPoints.Count - 1; i++)
    {
        DrawLine(_selectedPoints[i], _selectedPoints[i + 1]);
    }
}

もし DrawLine が Canvas の座標(=ピクセル/DIP)を期待しているなら、これは次のような状態になります。

変数中身単位本来の扱い
point0, 20, 40, …Canvas 座標(px/DIP)描画にそのまま使える
correctedPoint0, 1, 2, …グリッド番号描画前に spacing 倍して戻す必要がある
DrawLine に渡している点0, 1, 2, …(誤って)px/DIP として扱われる縮み・ずれの原因

つまり、

  • チェックボックス側:ピクセル座標(0,20,40…)
  • 選択リスト:グリッド番号(0,1,2…)
  • 描画側:ピクセル座標だと思って線を引く

という「単位の食い違い」が発生し、意図したグリッド交点を線が通らなくなります。

最短の修正:描画直前にピクセル座標へ戻してから線を引く

今の設計(_selectedPoints にグリッド番号を入れている)を活かしたまま最小変更で直すなら、描画直前に gridSpacing を掛け戻して、Canvas 用の座標に変換してから DrawLine に渡します。

if (response == ContentDialogResult.Primary)
{
    if (_selectedPoints.Count > 1)
    {
        for (int i = 0; i < _selectedPoints.Count - 1; i++)
        {
            // グリッド番号 → Canvas 上のピクセル座標へ変換
            var startPoint = new PointModel(
                _selectedPoints[i].X * gridSpacing,
                _selectedPoints[i].Y * gridSpacing);

            var endPoint = new PointModel(
                _selectedPoints[i + 1].X * gridSpacing,
                _selectedPoints[i + 1].Y * gridSpacing);

            DrawLine(startPoint, endPoint);
        }
    }
}

これで、ユーザーが選んだ (18,18) は描画時に (360,360) に戻り、グリッド交点を結ぶ線として期待どおりの位置に描画されます。

「戻し忘れ」を防ぐ小技

変換をその場で書くと、別の場所で再び「戻し忘れ」が起きがちです。最低限、変換をメソッド化しておくと安全性が上がります。

private PointModel GridToCanvas(PointModel gridPoint)
    => new PointModel(gridPoint.X * gridSpacing, gridPoint.Y * gridSpacing);

private PointModel CanvasToGrid(PointModel canvasPoint)
    => new PointModel(canvasPoint.X / gridSpacing, canvasPoint.Y / gridSpacing);

そして描画は必ず GridToCanvas を通します。

var startPoint = GridToCanvas(_selectedPoints[i]);
var endPoint   = GridToCanvas(_selectedPoints[i + 1]);
DrawLine(startPoint, endPoint);

別解:そもそも割らない設計にして、選択リストはピクセル座標で統一する

もし「描画がメイン」で「グリッド番号は表示のためにだけ欲しい」なら、チェック時点で割らずに、_selectedPoints は常に Canvas 座標(0,20,40…)を保持する方が、描画処理がシンプルになります。

チェック時の処理を簡略化する

void GuidCheckBox_CheckedChanged(object sender, RoutedEventArgs e)
{
    var checkBox = sender as CheckBox;
    var point = checkBox?.Tag as PointModel; // ここはピクセル座標のまま


if (point == null) return;

if (checkBox!.IsChecked == true)
{
    _selectedPoints.Add(point);
}
else
{
    _selectedPoints.Remove(point);
}


}

この設計だと、描画側は何も考えずに「Canvas 座標」として線を引けます。

for (int i = 0; i < _selectedPoints.Count - 1; i++)
{
    DrawLine(_selectedPoints[i], _selectedPoints[i + 1]);
}

グリッド番号が必要なときだけ計算する

たとえば UI に「(GridX, GridY)」を表示したいときは、表示直前に割って求めます。

var gridX = (int)Math.Round(point.X / gridSpacing);
var gridY = (int)Math.Round(point.Y / gridSpacing);

「データはピクセルで統一」「グリッドは表示時だけ」—この方針にすると、座標系の混在バグが起きにくくなります。

再発しにくいおすすめ設計:座標モデルを分けて “単位” を型で守る

今回のようなバグは、変換自体よりも「単位が違う値を同じ型(PointModel)で持ってしまう」ことで起きます。つまり、

  • ピクセル座標(Canvas 座標)
  • グリッド座標(マス番号)

を同じ PointModel に入れてしまうと、どこかで混ざります。そこで、型を分けるのが最も強力です。

例:GridPoint と CanvasPoint を分ける

public readonly record struct GridPoint(int X, int Y);
public readonly record struct CanvasPoint(double X, double Y);

public static class GridConverter
{
public static CanvasPoint ToCanvas(GridPoint p, double spacing)
=> new CanvasPoint(p.X * spacing, p.Y * spacing);


public static GridPoint ToGrid(CanvasPoint p, double spacing)
    => new GridPoint(
        (int)Math.Round(p.X / spacing),
        (int)Math.Round(p.Y / spacing));


}

この形にすると、描画メソッドは CanvasPoint しか受け取れないように作れます。

private void DrawLine(CanvasPoint start, CanvasPoint end)
{
    var line = new Microsoft.UI.Xaml.Shapes.Line
    {
        X1 = start.X,
        Y1 = start.Y,
        X2 = end.X,
        Y2 = end.Y,
        StrokeThickness = 2,
        // Stroke は適宜設定(例:SolidColorBrush)
    };

    MyCanvas.Children.Add(line);
}

そして「選択点をグリッドとして持つ」設計なら、描画の直前で必ず変換が入ります。

List<GridPoint> _selectedPoints = new();

for (int i = 0; i < _selectedPoints.Count - 1; i++)
{
    var s = GridConverter.ToCanvas(_selectedPoints[i], gridSpacing);
    var e = GridConverter.ToCanvas(_selectedPoints[i + 1], gridSpacing);
    DrawLine(s, e);
}

単位を間違えるとコンパイルが通りにくくなるため、「戻し忘れ」や「割りっぱなし」を根本から減らせます。

チェックボックスの Tag に入れる値はどうするのが正解か

Tag は便利ですが、型が曖昧になりやすい場所です。今回のケースでは、以下のどれかに寄せると設計が安定します。

Tag に入れる値メリット注意点おすすめ度
Canvas 座標(0,20,40…)描画が単純。変換不要。グリッド番号表示が必要なときに都度変換。高
グリッド座標(0,1,2…)ロジックがグリッド中心にまとまる。描画前に必ず spacing 倍が必要。高
両方(Grid と Canvas を持つ DTO)場面に応じて取り出せる。二重管理で値不整合が起きないよう注意。中
変換済み/変換前が混在(なし)今回のようなバグが再発しやすい。低

Tag にグリッド座標を入れる場合の例

チェックボックスの生成時点で、Tag には “グリッド番号” を入れておきます。

// col,row は 0..N のグリッド番号
checkBox.Tag = new GridPoint(col, row);

描画するときだけ Canvas 座標に変換します。

var gp = (GridPoint)checkBox.Tag;
var cp = GridConverter.ToCanvas(gp, gridSpacing);

Tag に Canvas 座標を入れる場合の例

逆に、Tag には Canvas 座標を入れておき、表示するときだけ割ります。

checkBox.Tag = new CanvasPoint(col * gridSpacing, row * gridSpacing);

実装でハマりやすい落とし穴

今回の原因は「割ったまま描画」ですが、同種の “線がズレる” 現象には他にも地雷があります。併せて確認すると、デバッグが速くなります。

整数除算で端数が落ちる

point.X や gridSpacing が int の場合、割り算結果は整数になり、端数が切り捨てられます。グリッドにきっちり乗る前提なら問題が出にくいですが、少しでもズレた座標を扱うと誤差が累積します。

安全策として、変換は double を使い、グリッド化する場面では Math.Round などを明示します。

double spacing = 20.0;
int gridX = (int)Math.Round(canvasX / spacing);
int gridY = (int)Math.Round(canvasY / spacing);

点の「中心」を結んでいない

チェックボックスや UI 要素を基準に座標を作っている場合、交点ではなく「要素の左上」を使ってしまうことがあります。線の始点・終点が交点から少しずれて見える場合は、中心座標を使っているかを確認してください。

たとえばチェックボックスの中心を狙うなら、幅・高さの半分を足します(配置方法によっては不要です)。

// 例:UI 要素の左上基準の座標から中心へ
var centerX = left + element.ActualWidth / 2.0;
var centerY = top  + element.ActualHeight / 2.0;

Canvas に Transform(拡大縮小・回転)がかかっている

Canvas そのものや親要素に RenderTransform / ScaleTransform がかかっていると、「論理座標」と「見た目」が一致しないことがあります。ズレ方が一定比率で大きくなったり、拡大率に応じて変わったりするなら、Transform を疑ってください。

必要に応じて TransformToVisual を使い、座標系を揃えた上で線を引きます。

// 例:ある要素の座標を Canvas の座標系へ変換
GeneralTransform t = someElement.TransformToVisual(MyCanvas);
Point pOnCanvas = t.TransformPoint(new Point(0, 0));

WinUI の「px」と「物理mm」は一致しない

質問の中で「100mm × 100mm」と表現されていても、WinUI の XAML レイアウトは基本的に DIP(Device Independent Pixel)基準です。モニター DPI やスケーリング設定により、画面上の “物理的な mm” と “数値としての px/DIP” は一致しません。

ただし今回の症状((18,18) が縮んで見える等)は、DPI というより「単位の混在」で説明できるため、まずは座標変換を直すのが優先です。その上で、物理サイズに厳密に合わせたい場合は DPI を考慮した換算が必要になります。

デバッグが一気に楽になる確認方法

線がずれるときは「線が悪い」のではなく「点が間違っている」ことがほとんどです。点を可視化すると、原因の切り分けが速くなります。

選択点に小さなマーカー(円)を描く

線の始点・終点が想定位置かどうかを一発で確認できます。

private void DrawMarker(double x, double y)
{
    var ellipse = new Microsoft.UI.Xaml.Shapes.Ellipse
    {
        Width = 6,
        Height = 6,
        StrokeThickness = 2,
        // Stroke / Fill は適宜
    };

    // マーカーの中心が (x,y) になるようにオフセット
    Canvas.SetLeft(ellipse, x - 3);
    Canvas.SetTop(ellipse, y - 3);

    MyCanvas.Children.Add(ellipse);
}

そして線を引く直前に、実際に使う座標でマーカーを描きます。

var s = GridConverter.ToCanvas(_selectedPoints[i], gridSpacing);
var e = GridConverter.ToCanvas(_selectedPoints[i + 1], gridSpacing);

DrawMarker(s.X, s.Y);
DrawMarker(e.X, e.Y);
DrawLine(s, e);

マーカーが正しい交点に乗っているなら、線側の問題(Transform や SetLeft/Top の計算ミスなど)に絞れます。マーカー自体がズレているなら、点の生成・変換が原因です。

ログに「グリッド」と「Canvas」両方を出す

座標変換バグはログで見抜けます。値の単位が曖昧なままだと追跡が難しいので、出力も単位付きで行うと効果的です。

var gp = _selectedPoints[i];
var cp = GridConverter.ToCanvas(gp, gridSpacing);
System.Diagnostics.Debug.WriteLine(
    $"Grid=({gp.X},{gp.Y})  Canvas=({cp.X},{cp.Y}) spacing={gridSpacing}");

実用面での改善案:線をたくさん引くなら Polyline / Path に寄せる

点が増えて線分が多くなると、Line 要素を大量に生成して Canvas.Children に詰めるより、Polyline(折れ線)を使った方が管理が楽になることがあります。特に「選んだ点を順番に結ぶ」用途なら、Polyline は相性が良いです。

例えば、グリッド点を Canvas 座標に変換して Points に入れるだけで描画できます。

private void DrawPolyline(IEnumerable<GridPoint> gridPoints)
{
    var polyline = new Microsoft.UI.Xaml.Shapes.Polyline
    {
        StrokeThickness = 2,
        // Stroke は適宜
    };

    foreach (var gp in gridPoints)
    {
        var cp = GridConverter.ToCanvas(gp, gridSpacing);
        polyline.Points.Add(new Windows.Foundation.Point(cp.X, cp.Y));
    }

    MyCanvas.Children.Add(polyline);
}

今回の主題(座標の単位)とは少し外れますが、実装が進んで機能が増えるほど、こうした描画要素の選択も効いてきます。

まとめ:座標は「何の単位か」を決め、変換は必ず一方向に統一する

WinUI の Canvas で線がずれて見えるとき、最初に疑うべきは「座標の単位が途中で変わっていないか」です。今回のケースでは、チェック時に / gridSpacing でグリッド番号にしたのに、そのまま描画してしまったことが原因でした。

  • 最短の修正:描画直前に gridSpacing を掛け戻して Canvas 座標に変換してから線を引く
  • 設計として安定:選択リストはピクセル座標で統一し、表示時だけグリッド化する
  • 最も再発しにくい:グリッド座標と Canvas 座標を型で分け、変換関数を必ず通す

座標系が整理されると、線のずれだけでなく、今後追加する「スナップ」「拡大縮小」「保存・読み込み」などの機能も安定して実装しやすくなります。まずは “どの変数がどの単位なのか” を明確にし、変換を一箇所に集約するところから始めてみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次