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)を期待しているなら、これは次のような状態になります。
| 変数 | 中身 | 単位 | 本来の扱い |
|---|---|---|---|
| point | 0, 20, 40, … | Canvas 座標(px/DIP) | 描画にそのまま使える |
| correctedPoint | 0, 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 座標を型で分け、変換関数を必ず通す
座標系が整理されると、線のずれだけでなく、今後追加する「スナップ」「拡大縮小」「保存・読み込み」などの機能も安定して実装しやすくなります。まずは “どの変数がどの単位なのか” を明確にし、変換を一箇所に集約するところから始めてみてください。

コメント