レンダリングとカメラ
キャンバスは素の 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 に等しくなります。パネルの背後にワークスペースの背景を塗ります。
パネル矩形にドロップシャドウと下地のべた塗りを、画面空間で描きます。role を持つ v5 スタックなら下地はむき出しの FR4 基材で(黒いソルダーマスクは後段で銅の上に合成されます)、v5 より前のフラットなレイヤー配列では従来どおり黒い下地になります。
Show content outside the panel がオンのときは、パネル外のゴーストパスを実行します。パネルの端からはみ出したレイヤーの内容を、減光した状態で、下のクリップ済みパスより 先に、パネル外の切り離された領域に描きます。
レイヤーパス全体に対する 1 回の
setTransform相当の処理:ctx.save()、パネル矩形へのクリップ、ctx.translate(offsetX, offsetY)、ctx.scale(pxPerMm, pxPerMm)。ここから対応するctx.restore()までは キャンバスの 1 単位 = 1mm となり、パターンジェネレータ自身の座標契約(パターン → ジェネレータ契約を参照)と一致します。projectPcbLayerSlices()が表示中の通常の子を Copper / Solder mask / Silkscreen のスライスへ振り分け、祖先/コンテナの表示状態を畳み込み、null でない paint を所有マテリアルへ強制します。Copper と Silkscreen はその物理順で正のまま描かれますが、Solder mask のスライスだけは違います。その leaf はdestination-outでパネル全面の黒いシート(mask-sheet.ts)から抜かれ、そのシートが銅の上に合成されるため、マスクのアートワークは開口として読まれます。Solder mask コンテナが 非表示 のときはシート自体を作らず——マスクはどこにもなく、基材の上に銅がむき出しになります——空でも 表示されていれば 全面のシートが合成されます。復元し、パネルの輪郭を画面空間でストロークします。
ガイドを描きます。ビューポート全体にわたる細い線で、レイヤーの内容の上、選択クロムの下に描かれます。
クリップされない 選択クロムを描きます(選択中のレイヤーごとに 1 つの破線 bbox、2 つ以上選択されているときはそれらをまとめた合成 bbox、そして正確に 1 つだけ選択されているときにのみリサイズ/回転ハンドルとパスノードのアンカー/ハンドル)。
アクティブなツールに
renderDraftフックがあれば呼び出し、作成中のジェスチャー(たとえばペンツールのドラフトパスや、選択ツールのマーキー/ホバー)を他のすべての上に描きます。
レイヤー型ごとの描画
drawLayer() は mm 空間の変換の中で layer.type によって分岐します。
shape — 投影された所有マテリアルで塗りつぶした
ctx.rect/ctx.ellipse。楕円の半径はMath.abs()を取ります。ctx.ellipseは負の半径でIndexSizeErrorを投げるためです。一方、負の幅/高さは(rectの分岐がすでに扱っているように)有効な(反転した)矩形として成立します。pattern —
patternTypeでPATTERN_GENERATORSから検索します。レイヤー自身のx/y/sizeの正方形には、まず独自のネストした変換がかかります——ctx.save()、ctx.translate(x, y)、(0,0)–(size,size)へのクリップ(呼び出し側が保持しているどんなクリップとも合成される、独立したクリップ操作として)、その後ctx.restore()です。そしてジェネレータのdraw()はその内側で、widthMm/heightMmをsizeにセットして呼ばれます(パターン → ジェネレータ契約を参照)。そのため、パネル空間ではなく、オブジェクトローカルな正方形空間で描画します。パターンが実際に描かれるのは、そのsizeが有限・正・MAX_PATTERN_SIZE_MM以内のときだけです。不正または法外なサイズ(たとえば手編集したインポート)は、ジェネレータの描画ループを無制限のスパンで回してしまう代わりに、黙ってスキップされます。path —
Path2D(@zpd/coreのbuildPath2D)に組み立て、閉じているときはevenoddルールで塗ります(穴を穴のまま保つためです。Solder mask コンテナでは同じ塗りが開口を抜き、その内側の even-odd の穴にはマスクが残ります)。ストロークが有効で幅が設定されていればストロークし、どちらの paint も投影された所有マテリアルを使います。text — 毎回の描画で
ensureFont()を fire-and-forget で呼びます。実際のフェイスがまだ読み込まれていなければ、ブラウザのフォールバックフェイスを直ちに描き、フォントが解決すると、それを変更したツールやインスペクタからの次の再描画要求で本物のグリフを反映します。複数行の内容は\nで分割し、行の高さは 1.25 倍です。image — キャッシュされた
<img>が読み込み済みならそれを描き、そうでなければ破線のプレースホルダー輪郭を描きます(画像はEditor.tsxのアセット読み込みエフェクトによってレイヤー ID をキーにデコード/キャッシュされます)。
回転した shape / text / image レイヤーは、型ごとの描画が走る前に、ctx.translate / ctx.rotate / ctx.translate によって自身の bbox 中心を軸に回転されます。path / pattern レイヤーには回転フィールドがありません。
パネル外のゴーストパス
かつてエディタはすべてのレイヤーをパネル矩形にクリップしていました。そのため、一部がパネルの外にドラッグされたレイヤーは、はみ出した部分がクリップで切り取られる一方、選択ハンドルだけは描かれ続けていました。その下に形が何もない、空のワークスペースの上に浮くハンドルが見えていたのです。Show content outside the panel という表示オプション(デフォルトはオン。インターフェース → 右サイドバーを参照)は、パネル外の領域を減光したゴーストとして描くことで、これを解消します。
このパスは、クリップされたパネル内のパスより 先に、独自の領域に対して実行されます。outsidePanelRegion()(outside-panel-region.ts、純粋で Node でテスト可能)が 2 つのクリップ矩形とどのレイヤーが対象になるかを決め、renderer.ts がそれをキャンバスの呼び出しに変換します。この領域は 2 つの矩形の even-odd(偶奇)クリップ です。ビューポート全体(outerRect)から パネル(innerRect)を 引いた もので、ちょうどパネルの 外側 の領域にクリップされ、パネルをクリップするパスとは重なりません。そのため、すべてのピクセルはちょうど 1 つのパスによって描かれます。この重ならないという性質は見た目のためではなく、本質的に重要です。単純なビューポート全体のクリップだと、ゴーストの内容をパネルの 下にも 描いてしまい、不透明度が 1 未満のパネル内レイヤーが、自分自身のフル不透明度の描画に対して二重に合成されてしまいます。これはこの機能が慎重に避けた、実在する Porter-Duff アルファのバグです。クリップの内側では、ゴーストの内容は 35% のアルファ で描かれます。
なぜ減光か、そしてなぜ表示専用か
この減光は意図的な設計判断であり、後から不透明度 1 へ「修正」されないよう、コードとここに記録されています。zpd では、パネルの端より外側の領域は製造時に 物理的に切り落とされます。ですから減光したゴーストは、単に「これは脇に置かれている」ではなく、「これは製造されない」ことを表します。
このトグルは 表示専用 です。エクスポートはこれまで一度もクリップしていません。serializePanelConfig(serialize.ts)は doc.layers を——パネル外のジオメトリも含めて——そのまま出力し、クリップはレンダラーの中だけに存在していました。したがって、ゴーストのオン/オフを切り替えても、それがゲートしているクリップと同じく、変わるのは 見えるもの だけで、注文するもの は決して変わりません。(パネル外のレイヤーは、実際のパネルにとっては依然としてモデリングのミスです。ゴーストは、それを黙ってクリップするのではなく、見えるようにするだけです。)
何がゴースト描画されるか
outsidePanelRegion は対象となるレイヤーを絞り込みます。
非表示のレイヤー は、ほかの場所と同様、決して描かれません。
パターンレイヤーは対象になります。パターンの正方形が実装されて以降のことです。 かつてここでパターンが除外されていたのは、自身の描画スパンを越えるパターンのオーバースキャン(
@zpd/patternsのcenteredStart())を、ゴースト描画するとガター全体がドットグリッドで埋め尽くされてしまうためでした。patternレイヤーは今や移動可能な 正方形 です——layerBbox()はパネル矩形ではなく、自身のx/y/sizeの矩形を返します(ツール → 選択ツール → パターンの正方形を参照)——そしてレンダラーは、パターンの描画をその正方形へ、呼び出し側が保持しているどんなクリップとも合成される独自のクリップ操作としてクリップします(ctx.save()/ctx.rect(0, 0, size, size)/ctx.clip())。この正方形単位のクリップが、ゴーストパスも含めて、あらゆる面でオーバースキャンを制限します。そのため、ゴースト表示されたパターンは、ほかのどのレイヤーとも同じ 35% のアルファで、ちょうど正方形 ∖ パネルを描くだけであり、かつての除外が防ごうとしていたような無制限のあふれは起きません。完全にパネル内に収まるレイヤーはスキップされます——これは純粋な性能上の間引きです。回転を考慮した bbox がパネル内に収まるレイヤーは、目に見えるゴーストのピクセルを一切生み出せません(外側のクリップが、そのレイヤーが描くものをすべて拒否します)。ですからここで描いても、そのレイヤーを——パスレイヤーであれば
Path2Dの再構築も含めて——再描画のたびにもう一度レンダリングするだけで、視覚的な効果はゼロです。数百のパスレイヤーを持つ大きなトレースでは、これは高コストになります。この間引きがあるため、(よくある)デフォルトでパネルを覆う パターンの正方形は、実際にはゴースト表示されません。その bbox はパネルと一致するため、境界チェックをそもそも越えないからです——ゴースト表示されるのは、ユーザーがパネルの端を越えてドラッグまたはリサイズした正方形だけです。
mm↔px の変換は変換境界にしか存在しない
レイヤーパスは全体が 1 つの ctx.scale(pxPerMm, pxPerMm) の中で走るため、すべてのジェネレータとレイヤー描画の分岐は純粋にミリメートルで動き、いずれも自分で pxPerMm を掛けることはありません。画面ピクセルで考える唯一のコードは、カメラモジュールそのもの、選択ツールのヒットテスト/ハンドルの計算(ハンドルのサイズはズームにかかわらず固定の 画面 ピクセル)、そして選択クロムのパスです。選択クロムは、そのストローク幅とハンドルサイズがどのズームでも一定の画面サイズを保つよう、意図的に mm 空間のクリップ/変換の 外 で描かれます。
Note
layerBbox() と resizeHandleRects() — ヒットテストとリサイズハンドルの背後にあるジオメトリヘルパー — は、レンダラーが描画に使うのとまったく同じ型ごとのジオメトリルールを必要とするため、描画コードとともに renderer.ts に置かれています。ツールは、バウンディングボックスを独自に再計算するのではなく、これらをインポートします。