レンダリングとカメラ
キャンバスは素の Canvas2D による 全再描画 レンダラーです。差分を取ってパッチを当てるのではなく、毎フレーム、現在のドキュメントからシーン全体を描き直します。座標は 2 つの空間 — ドキュメントのミリメートルと画面ピクセル — のいずれかにあり、その間を結ぶ唯一のマッピングが カメラ です。
カメラモデル
camera.ts はカメラを 3 つの数値として定義します。
interface Camera {
pxPerMm: number; // zoom — also the mm→px scale factor
offsetX: number; // screen px of document (0,0)
offsetY: number;
}project / unproject は正確な逆関数のペアです。
project(cam, mm) // { x: mm.x * cam.pxPerMm + cam.offsetX, y: mm.y * cam.pxPerMm + cam.offsetY }
unproject(cam, screen) // { x: (screen.x - cam.offsetX) / cam.pxPerMm, y: (screen.y - cam.offsetY) / cam.pxPerMm }ズームは [0.5, 100] px/mm(MIN_PX_PER_MM / MAX_PX_PER_MM)にクランプされます。0.5 でも 20HP のパネル全体を余裕をもって表示でき、100 は表示される数値が破綻することなく 0.1mm のノード編集ができるほど深いです。
| 関数 | 挙動 |
|---|---|
fit(panelWmm, panelHmm, viewport, margin=48) | パネルをビューポートの中央に、全辺に margin px の余白を残す最大のズームで配置する。最初の計測時と、パネルサイズが変わるたびに呼ばれる。 |
zoomAt(cam, screen, factor) | 現在 screen の下にある mm 座標の点をその場に留めたまま factor 倍にズームする。ホイールハンドラと ズームツールの両方が頼る、基点固定のズーム動作。 |
panBy(cam, dxPx, dyPx) | カメラの画面オフセットを平行移動する。パンツールがドラッグ移動のたびに呼ぶもの。 |
マウスホイールのズームは、Editor.tsx に非パッシブな wheel リスナー(ページのスクロールを止めるための preventDefault())として直接組み込まれ、同じ zoomAt によってカーソル位置を基点にします。
mm ルーラー
ビューポートを縁取るミリメートルルーラーの帯(インターフェース → キャンバスビューポートとルーラー)は、カメラの純粋なコンシューマー です。pxPerMm と対応するオフセットを読み取り、そこからすべてを導出するだけで、自前の状態は一切持ちません。目盛りの計算は ruler-ticks.ts(純粋・DOM 非依存・ユニットテスト済み)にあり、components/ はそれが返したものを描くだけです。
帯はカメラとまったく同じ座標モデル — screen = mm × pxPerMm + offset — を共有します。そのため、どんなパンやズームでも mm の 0 は常にカメラオフセット、すなわちパネルの左上隅に来ます。getRulerTicksMm(pxPerMm, offset, lengthPx, step) は、表示範囲にわたって整数のマイナー目盛りインデックスをたどり(パンの最中に端のラベルがちらつかないよう、両端を 1 目盛りずつオーバースキャンします)、各目盛りの mm を i × minor から計算します。これにより mm 0 が厳密に保たれ、「これはラベル付きのメジャー目盛りか?」の判定も、浮動小数点に強い i % 5 === 0 になります。
目盛りの間隔は、1-2-5 × 10ⁿ のはしごに沿ってズームに適応します。pickTickStepMm(pxPerMm) は、画面上のメジャー目盛りの間隔が約 50px 以上になる中で最小のステップを選ぶため、ラベルが混み合うことはありません。深くズームするとメジャーのステップはミリメートル未満まで下がり(そのときラベルは小数第 1 位まで表示します)、大きくズームアウトすると 10mm、20mm、50mm とそれ以上へ上がっていきます。マイナー(ラベルなし)の目盛りは、メジャーのステップの 5 分の 1 に置かれます。
重要なのは、帯が レイアウト上は固定 されている点です。20px の固定ガター内に収まり、パン/ズームの変換を受けることはありません。カメラが変わったときに描き直されるのは キャンバスの内容 だけで、要素自体は動きません。(ルーラーの DOM 要素の位置をスクロール/パンのオフセットに同期させるのは、よく知られたずれのバグの原因であり、ここでは設計としてそれを避けています。)各帯は目盛りをデバイスピクセルグリッドにスナップするため、どんな devicePixelRatio でも線がくっきり保たれます。
再描画ループ
renderer.ts の renderScene(canvas, doc, panel, cam, extras) は Editor.tsx のレンダーのたびに実行され(依存配列のない素のエフェクトです。このシーン規模なら常に走らせても十分に安価です)、毎フレーム次の処理を行います。
変換をリセットしてバッキングストア全体をクリアし、
devicePixelRatioでスケールします。これにより、画面の密度にかかわらずキャンバスの 1 単位が 1 CSS px に等しくなります。パネルの背後にワークスペースの背景を塗ります。
パネル矩形にドロップシャドウと黒のべた塗りを、画面空間で描きます。
レイヤーパス全体に対する 1 回の
setTransform相当の処理:ctx.save()、パネル矩形へのクリップ、ctx.translate(offsetX, offsetY)、ctx.scale(pxPerMm, pxPerMm)。ここから対応するctx.restore()までは キャンバスの 1 単位 = 1mm となり、パターンジェネレータ自身の座標契約(パターン → ジェネレータ契約を参照)と一致します。非表示でないすべてのレイヤーが、この単一の変換の中で配列順(後のレイヤーが上)に一度ずつ描かれます。復元し、パネルの輪郭を画面空間でストロークします。
選択中のレイヤーがあり、かつ非表示でなければ、そのレイヤーの クリップされない 選択クロム(破線の bbox、リサイズハンドル、パスノードのアンカー/ハンドル)を描きます。
アクティブなツールに
renderDraftフックがあれば呼び出し、作成中のジェスチャー(たとえばペンツールのドラフトパス)を他のすべての上に描きます。
レイヤー型ごとの描画
drawLayer() は mm 空間の変換の中で layer.type によって分岐します。
shape — レイヤーのパレット色で塗りつぶした
ctx.rect/ctx.ellipse。楕円の半径はMath.abs()を取ります。ctx.ellipseは負の半径でIndexSizeErrorを投げるためです。一方、負の幅/高さは(rectの分岐がすでに扱っているように)有効な(反転した)矩形として成立します。pattern —
patternTypeでPATTERN_GENERATORSから検索し、そのジェネレータのdraw()にパネル矩形全体を渡してそのまま委譲します。path —
Path2D(@zpd/coreのbuildPath2D)に組み立て、閉じているときはevenoddルールで塗り(トレースされた穴/島が穴のまま保たれるよう。画像のトレースを参照)、ストロークの色/幅が設定されていればストロークします。text — 毎回の描画で
ensureFont()を fire-and-forget で呼びます。実際のフェイスがまだ読み込まれていなければ、ブラウザのフォールバックフェイスを直ちに描き、フォントが解決すると、それを変更したツールやインスペクタからの次の再描画要求で本物のグリフを反映します。複数行の内容は\nで分割し、行の高さは 1.25 倍です。image — キャッシュされた
<img>が読み込み済みならそれを描き、そうでなければ破線のプレースホルダー輪郭を描きます(画像はEditor.tsxのアセット読み込みエフェクトによってレイヤー ID をキーにデコード/キャッシュされます)。
回転した shape / text レイヤーは、型ごとの描画が走る前に、ctx.translate / ctx.rotate / ctx.translate によって自身の bbox 中心を軸に回転されます。path / pattern / image レイヤーには回転フィールドがありません。
mm↔px の変換は変換境界にしか存在しない
レイヤーパスは全体が 1 つの ctx.scale(pxPerMm, pxPerMm) の中で走るため、すべてのジェネレータとレイヤー描画の分岐は純粋にミリメートルで動き、いずれも自分で pxPerMm を掛けることはありません。画面ピクセルで考える唯一のコードは、カメラモジュールそのもの、選択ツールのヒットテスト/ハンドルの計算(ハンドルのサイズはズームにかかわらず固定の 画面 ピクセル)、そして選択クロムのパスです。選択クロムは、そのストローク幅とハンドルサイズがどのズームでも一定の画面サイズを保つよう、意図的に mm 空間のクリップ/変換の 外 で描かれます。
Note
layerBbox() と resizeHandleRects() — ヒットテストとリサイズハンドルの背後にあるジオメトリヘルパー — は、レンダラーが描画に使うのとまったく同じ型ごとのジオメトリルールを必要とするため、描画コードとともに renderer.ts に置かれています。ツールは、バウンディングボックスを独自に再計算するのではなく、これらをインポートします。