第 26 課 - 選択ツール
第 14 課 - 選択モード では、個々の図形をクリックして選択する単純な機能だけを実装しました。この課では、複数選択、マーキー選択、なげなわを加えて選択ツールをさらに整えます。
複数選択
個別クリックによる選択を土台に、Shift を押したまま、現在の選択へ図形を追加したり外したりできます。

選択モードでは、input.shiftKey の状態、つまり Shift が押されているかによって、現在の選択を残すかどうかを決めます。押されていなければ単一選択に切り替え、押されていれば対象の図形を既存の選択へ追加します。
if (selection.mode === SelectionMode.SELECT) {
if (layersSelected.length > 1 && layersSelected.includes(selected.id)) {
// deselect if already selected in a group
api.deselectNodes([selected]);
} else {
api.selectNodes([selected], input.shiftKey); // whether to add to existed selection
}
}第 21 課 - トランスフォーマー では、図形を一つ選択する機能を実装しました。次に、複数の図形が選択された状態を示す Group 表示を追加する必要があります。単一選択のトランスフォーマーと異なり、複数選択の Group は rotation と scale を考慮しなくてよいです。
export class RenderTransformer extends System {
getOBB(camera: Entity): OBB {
const { selecteds } = camera.read(Transformable);
// Single selected, keep the original OBB include rotation & scale.
if (selecteds.length === 1 && selecteds[0].has(ComputedBounds)) {
const { obb } = selecteds[0].read(ComputedBounds);
return obb;
}
if (selecteds.length > 1) {
}
}
}効果は次のとおりです。リサイズ時に選択したすべての図形を変換するロジックは 第 21 課 - 図形の変換 で既に紹介したので、ここでは繰り返しません。
マーキー選択
次の図は Select layers and objects in Figma からのものです。

このマーキー選択ツールは “marquee” と呼ばれます。参照: Make selections with the Rectangular Marquee tool。形成された矩形領域をブラシ(Brush)と呼びます。
マーキー選択が終わるとき(マウスボタンを離したとき)、まずブラシの矩形を隠す必要があります。実装は次の節で見ます。そのあと、第 8 課 - 空間インデックスによる高速化 で紹介した高速なピッキングを使います。矩形の幅と高さが負になることがあるため(ドラッグ方向によります)、BBox が有効になるよう計算が必要である点に注意してください。
if (input.pointerUpTrigger) {
if (selection.mode === SelectionMode.BRUSH) {
// Hide Brush...
if (selection.brush) {
const { x, y, width, height } = selection.brush.read(Rect);
// Make a valid BBox
const minX = Math.min(x, x + width);
const minY = Math.min(y, y + height);
const maxX = Math.max(x, x + width);
const maxY = Math.max(y, y + height);
const selecteds = api
.elementsFromBBox(minX, minY, maxX, maxY) // Use space index
.filter((e) => !e.has(UI))
.map((e) => api.getNodeByEntity(e));
api.selectNodes(selecteds); // Finish selection
}
}
}マーキー選択の途中でも、ハイライトとトランスフォーマーで選択状態をリアルタイムに示したいと考えています。上のピッキングと選択のロジックを土台に、ハイライトを追加します。

api.selectNodes(selecteds);
if (needHighlight) {
api.highlightNodes(selecteds);
}もちろん、ビューポート空間内のコンポーネントは SVG でも実装でき、続くなげなわツールでその例を見ます。
Esc で選択解除
選択状態で Esc を押すと選択が解除されます。また、マーキー選択の途中ではブラシを隠す必要があります。
if (input.key === 'Escape') {
api.selectNodes([]);
if (selection.mode === SelectionMode.BRUSH) {
this.hideBrush(selection);
}
}レイヤーのロックとロック解除
ロックされたレイヤーは選択できません。詳細は Lock and unlock layers を参照してください。
なげなわ選択
矩形マーキーツールと比べて、なげなわツールは不規則な多角形を作ることで、より精密な選択ができます。
AI ベースの画像編集では、なげなわツールでより精密にマスクを作り、インペインティングを行うこともできます。下の図は Figma での効果で、選択した領域は消去したり、レイヤーへ分離したりできます。
なげなわを描く
第 25 課 - 鉛筆ツール では、ポリラインのパスを自由に描く方法を扱いました。SVG コンテナの中でなげなわのパスを描き続け、通常はアニメーション効果(アリの行進)のある破線で表します。
<path d="...">
<animate
attribute-name="stroke-dashoffset"
stroke-dasharray="7 7"
stroke-dashoffset="10"
from="0"
to="-14"
dur="0.3s"
/>
</path>まず、点の座標をビューポート座標系からキャンバス座標系へ変換します。次に、現在のカメラのズームレベルに応じてパスを単純化します。ズームレベルが高ければより細かい選択粒度が必要で、低ければその逆です。また、頂点が少ないほど、レンダリング性能とその後の交差判定の効率がどちらも上がります。
import simplify from 'simplify-js';
const lassoPath = super
.getCurrentTrail()
?.originalPoints?.map(([x, y]) => this.api.viewport2Canvas({ x, y }));
const simplifyDistance = 5 / this.api.getAppState().cameraZoom;
const points = simplify(lassoPath, simplifyDistance).map((p) => [p.x, p.y]);
// Hit-test the current path as it changes for preview, and again on pointerup
// before clearing the trail to commit the final selection.
const selected = selectByLassoPath(this.api, points);図形の周りをドラッグすると、ヒットがすぐにプレビューされます。なげなわが変わると輪郭線が表示されたり消えたりします。未完成のなげなわは、ヒットテストのために始点へ自動的に閉じられます。離すと、最終的な選択が 1 回の元に戻す操作として確定します。Escape を押す、タッチジェスチャを取り消す、ピンチを始める、またはツールを切り替えると、選択せずにプレビューが消えます。タップやごく小さな動きでは何も選択されません。もう一度選択するには、なげなわツールを選び直します。マスク描画モードは離したときにだけパスを作り、図形選択のプレビューは行いません。
ヒットテストはワールド座標を使い、各図形の親の変換、回転、反転を含みます。開いたポリラインは、暗黙の閉じ辺を足さず、区間ごとに判定します。ロックされた図形と非表示の図形は除外されます。
多角形の交差判定
Path を作ったので、シーンの中でそれと交差するジオメトリを取得する必要があります。これまでどおり、第 8 課 - 空間インデックスによる高速化 から使い始めます。
function selectByLassoPath(api: API, lassoPath: [number, number][]) {
const lassoBounds = lassoPath.reduce(
(acc, item) => {
return [
Math.min(acc[0], item[0]),
Math.min(acc[1], item[1]),
Math.max(acc[2], item[0]),
Math.max(acc[3], item[1]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
) as [number, number, number, number];
// Hit-test with rbush
const elements = api.elementsFromBBox(
lassoBounds[0],
lassoBounds[1],
lassoBounds[2],
lassoBounds[3],
);
// elementsFromBBox excludes locked elements; also filter UI and hidden nodes.
}高速なバウンディングボックス判定を通過したあと、二つの場合を処理しなければなりません。なげなわが図形の内部に完全に含まれる場合と、なげなわが図形と交差する場合です。
function isPolygonsIntersect(points1: number[][], points2: number[][]) {
let isIn = false;
// Determine whether a point lies inside a polygon. If any point is inside another polygon, return true.
points2.forEach((point) => {
if (isPointInPolygon(points1, point[0], point[1])) {
isIn = true;
return false;
}
});
if (isIn) {
return true;
}
}