最近、グラフィックエディタに基準線吸着機能を追加したので、その実装アイデアについてお話します。
私が開発しているグラフィックデザインツール:
https://github.com/F-star/suika
オンライン体験:
https://blog.fstars.wang/app/suika/
その結果、移動されたグラフィックスが周囲のグラフィックスを参照し、自動的にそれらと位置合わせされます。
かなりクールだと言わざるを得ません。
このグラフィック エディターが突然、魂を持って機敏になったように感じます。
なぜ基準線スナップ機能が必要なのでしょうか?
ここでいう基準線とは、対象のグラフィックを移動する際に、他のグラフィックのバウンディングボックスの延長線(見えない)に近い場合、(1)最も近い延長線とその延長線上の点を描画します。 (2) ターゲットグラフィックスを吸収し、(3) の位置合わせの効果を容易に実現します。
基準線を介して、2 つのグラフィックの下端と固定端を揃えたり、右下隅と左上隅を揃えたりするなど、さまざまな位置合わせを簡単に実現できることがわかります。
これは、配置が基本要素であるビジュアル デザインにおいて非常に便利な機能です。
全体のアイデア
全体的な考え方は次のとおりです。
- レコード基準線。
- ターゲットグラフィックに最も近い水平基準線と垂直基準線を見つけます。
- オフセット値offsetX、offsetYを計算します。
- 描画するすべての基準線分をマークします (両端が無限に延長されていない)。
- グラフの x と y を修正します。
- 基準線と点を描きます。
レコード基準線
1つ目は、「参考」として使用できる参照グラフィックスを決定することです。
一般に、基準シェイプはビューポート内のシェイプであり、移動されたターゲット シェイプは除外されます。ビューポートの外側のグラフィックスは、通常、デザイナーの関心の範囲外です。
参照図形を確認したら、その境界ボックス(bbox)を計算します。
今回の境界ボックスは少し特殊で、正中線が基準線としても使用されるため、追加の中点座標が必要です。
インターフェイスの署名は次のとおりです。
export interface IBoxWithMid {
minX: number;
minY: number;
midX: number;
midY: number;
maxX: number;
maxY: number;
}
これらが基準グラフの8点であり、これらの点に沿って縦横の線を引くことが移動対象グラフに対応する吸収基準線となる。
移動されたグラフィックスも境界ボックスを計算し、5 ポイントを取得します。これらの点に基づいて生成された水平線と垂直線は、水平方向の動きと垂直方向の動きの 2 つの次元に分割される基準線に近づくと、最も近い基準線にスナップします。
エディターへの影響:
グラフィックスを移動する準備ができたとき (マウスダウン時)、最初にすべての基準線を記録する必要があります。大きく分けて以下のような操作があります。
- 参照グラフィックスをトラバースします(移動ターゲット グラフィックスではなくビューポート内)。
- 境界ボックスを計算し、8 つの点、3 つの垂直線と 3 つの水平線を取得します。垂直線上の複数の点は、x 値は同じですが、y 値が異なります。x をキーとして使用し、y の配列を値として使用し、hLineMap マッピング オブジェクトに保存します。各項目は垂直線を表します。
- 同じことが、vLineMap に保存される水平線にも当てはまります。
- 次に、2 つのマップのキーをsortedXs 配列またはsortedYs 配列に保存し、それらを並べ替えます。これにより、後で二分検索で検索効率を向上させることができます。
RefLine (基準線) クラスを抽象化します。
interface IVerticalLine {
// 有多个端点的垂直线
x: number;
ys: number[];
}
interface IHorizontalLine {
// 有多个端点的水平线
y: number;
xs: number[];
}
class RefLine {
// 参照图形产生的垂直参照线,y 相同(作为 key),x 值不同(作为 value)
private hLineMap = new Map<number, number[]>();
// 参照图形产生的水平照线,x 相同(作为 key),y 值不同(作为 value)
private vLineMap = new Map<number, number[]>();
// 对 hLineMap 的 key 排序,方便高效二分查找,找到最近的线
private sortedXs: number[] = [];
// 对 vLineMap 的 key 排序
private sortedYs: number[] = [];
private toDrawVLines: IVerticalLine[] = []; // 等待绘制的垂直参照线
private toDrawHLines: IHorizontalLine[] = []; // 等待绘制的水平参照线
constructor(private editor: Editor) {
}
cacheXYToBbox() {
this.clear();
const hLineMap = this.hLineMap;
const vLineMap = this.vLineMap;
const selectIdSet = this.editor.selectedElements.getIdSet();
const viewportBbox = this.editor.viewportManager.getBbox2();
for (const graph of this.editor.sceneGraph.children) {
// 排除掉被移动的图形
if (selectIdSet.has(graph.id)) {
continue;
}
const bbox = bboxToBboxWithMid(graph.getBBox2());
// 排除在视口外的图形
if (!isRectIntersect2(viewportBbox, bbox)) {
continue;
}
// 将参照图形记录下来
// 这里是水平线,特点是 x 相同。
this.addBboxToMap(hLineMap, bbox.minX, [bbox.minY, bbox.maxY]);
this.addBboxToMap(hLineMap, bbox.midX, [bbox.minY, bbox.maxY]);
this.addBboxToMap(hLineMap, bbox.maxX, [bbox.minY, bbox.maxY]);
this.addBboxToMap(vLineMap, bbox.minY, [bbox.minX, bbox.maxX]);
this.addBboxToMap(vLineMap, bbox.midY, [bbox.minX, bbox.maxX]);
this.addBboxToMap(vLineMap, bbox.maxY, [bbox.minX, bbox.maxX]);
}
this.sortedXs = Array.from(hLineMap.keys()).sort((a, b) => a - b);
this.sortedYs = Array.from(vLineMap.keys()).sort((a, b) => a - b);
}
private addBboxToMap(
m: Map<number, number[]>,
xOrY: number,
xsOrYs: number[],
) {
const line = m.get(xOrY);
if (line) {
line.push(...xsOrYs);
} else {
m.set(xOrY, [...xsOrYs]);
}
}
// ...
}
最も近い基準線を見つける
次に、対象のグラフィックに最も近い水平基準線と垂直基準線を見つけます。
このステップは、グラフィックスが移動する (mousemove) ときに実行され、動的に変化します。
まず、対象グラフのminX、midX、maxXのそれぞれに最も近い垂直基準線を求めます。
次に、それぞれの絶対距離を計算します。
最後にその中で一番小さいものを見つけます。
class RefLinet {
updateRefLine(_targetBbox: IBox2): {
offsetX: number;
offsetY: number;
} {
// 重置
this.toDrawVLines = [];
this.toDrawHLines = [];
// 目标对象的包围盒,这里补上 midX,midY
const targetBbox = bboxToBboxWithMid(_targetBbox);
const hLineMap = this.hLineMap;
const vLineMap = this.vLineMap;
const sortedXs = this.sortedXs;
const sortedYs = this.sortedYs;
// 一个参照图形都没有,结束
if (sortedXs.length === 0 && sortedYs.length === 0) {
return {
offsetX: 0, offsetY: 0 };
}
// 如果 offsetX 到最后还是 undefined,说明没有找到最靠近的垂直参照线
let offsetX: number | undefined = undefined;
let offsetY: number | undefined = undefined;
// 分别找到目标图形的 minX、midX、maxX 的最近垂直参照线
const closestMinX = getClosestValInSortedArr(sortedXs, targetBbox.minX);
const closestMidX = getClosestValInSortedArr(sortedXs, targetBbox.midX);
const closestMaxX = getClosestValInSortedArr(sortedXs, targetBbox.maxX);
// 分别计算出距离
const distMinX = Math.abs(closestMinX - targetBbox.minX);
const distMidX = Math.abs(closestMidX - targetBbox.midX);
const distMaxX = Math.abs(closestMaxX - targetBbox.maxX);
// 找到最近距离
const closestXDist = Math.min(distMinX, distMidX, distMaxX);
// y 同理
}
}
ここでは、ソートされた配列内でターゲット値に最も近い配列要素を見つけるという、より重要なアルゴリズムを示します。
このアルゴリズムの二分探索の変形の原理は複雑ではありませんが、一度に正しく記述するのは非常に困難です。ここでは gpt に執筆を手伝ってもらいました。完璧です。
実装は次のとおりです。
const getClosestValInSortedArr = (
sortedArr: number[],
target: number,
) => {
if (sortedArr.length === 0) {
throw new Error('sortedArr can not be empty');
}
if (sortedArr.length === 1) {
return sortedArr[0];
}
let left = 0;
let right = sortedArr.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (sortedArr[mid] === target) {
return sortedArr[mid];
} else if (sortedArr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
// check if left or right is out of bound
if (left >= sortedArr.length) {
return sortedArr[right];
}
if (right < 0) {
return sortedArr[left];
}
// check which one is closer
return Math.abs(sortedArr[right] - target) <=
Math.abs(sortedArr[left] - target)
? sortedArr[right]
: sortedArr[left];
};
オフセットを計算する
先ほど、最小距離nearestXDistを取得しました。
次に、それが特定の臨界値 tol 未満であるかどうかを判断する必要があります。10メートル離れていることは不可能であり、それを動かすと、何千マイルも離れた場所からでも引き寄せられるでしょう。
満足した場合は続行します。
offsetX はあと 1 ステップですが、closestXDist は絶対値であるため、正か負かを判断する必要があります。
次に、この最小距離を、以前に計算した 3 つの距離 distMinX、distMidX、distMaxX と比較し、等しい距離を見つけて offsetX を計算します。
const isEqualNum = (a: number, b: number) => Math.abs(a - b) < 0.00001;
const tol = 5 / zoom; // 最小距离不能超过这个
// 确认偏移值 offsetX
if (closestXDist <= tol) {
// 这里考虑了一下浮点数误差
if (isEqualNum(closestXDist, distMinX)) {
offsetX = closestMinX - targetBbox.minX;
} else if (isEqualNum(closestXDist, distMidX)) {
offsetX = closestMidX - targetBbox.midX;
} else if (isEqualNum(closestXDist, distMaxX)) {
offsetX = closestMaxX - targetBbox.maxX;
} else {
throw new Error('it should not reach here, please put a issue to us');
}
}
offsetY についても同様なので、詳細は説明しません。
描画する基準線分をマークします
offsetX と offsetY が計算されます。
次に、targetBbox を修正する必要があります。
const correctedTargetBbox = {
...targetBbox };
if (offsetX !== undefined) {
correctedTargetBbox.minX += offsetX;
correctedTargetBbox.midX += offsetX;
correctedTargetBbox.maxX += offsetX;
}
if (offsetY !== undefined) {
correctedTargetBbox.minY += offsetY;
correctedTargetBbox.midY += offsetY;
correctedTargetBbox.maxY += offsetY;
}
ターゲットグラフィックスを修正すると、そのエッジはいくつかの基準線に合わせられます。
位置合わせのための基準線がない場合もあれば、1 つしかない場合もあれば、最大 6 つある場合もあります。
新しいターゲット グラフィックに基づいて、それがどの基準線に該当するかを調べてみましょう。
// offsetX 不为 undefined,说明落在了临界值内
if (offsetX !== undefined) {
/*************** 左垂直的参考线 ************/
// 对比 “offset” 和 “离 minX 最近的垂直线到 minX 的距离(不是绝对值)”
if (isEqualNum(offsetX, closestMinX - targetBbox.minX)) {
// 创建一个垂直线对象(特点是这些点的 x 相同)
const vLine: IVerticalLine = {
x: closestMinX,
ys: [],
};
// 修正后的目标图形的对应点。
vLine.ys.push(correctedTargetBbox.minY);
vLine.ys.push(correctedTargetBbox.maxY);
// 参照图形上的点
vLine.ys.push(...hLineMap.get(closestMinX)!);
// 添加到 “待绘制垂线集合”
this.toDrawVLines.push(vLine);
}
/*************** 中间垂直的参考线 ************/
if (isEqualNum(offsetX, closestMidX - targetBbox.midX)
) {
const vLine: IVerticalLine = {
x: closestMidX,
ys: [],
};
vLine.ys.push(correctedTargetBbox.midY);
vLine.ys.push(...hLineMap.get(closestMidX)!);
this.toDrawVLines.push(vLine);
}
/*************** 右垂直的参考线 ************/
// ...
}
// 水平线同理
if (offsetY !== undefined) {
/*************** 上水平的参考线 ************/
/*************** 中间水平的参考线 ************/
/*************** 下水平的参考线 ************/
}
グラフのx、yを修正します
計算された offsetX と offsetY を使用して、移動ターゲット グラフィックスの x と y を修正することを忘れないでください。
const onMousemove = (e) => {
// ...
const {
offsetX, offsetY } = this.editor.refLine.updateRefLine(
bboxToBbox2(this.editor.selectedElements.getBBox()!),
);
// 修正
for (let i = 0, len = selectedElements.length; i < len; i++) {
selectedElements[i].x = startPoints[i].x + dx + offsetX;
selectedElements[i].y = startPoints[i].y + dy + offsetY;
}
}
基準線と点を描画する
最後に、基準線を描画します。例として垂直線を描画します。
for (const vLine of this.toDrawVLines) {
let minY = Infinity;
let maxY = -Infinity;
// 这个是世界坐标系转视口坐标系
const {
x } = this.editor.sceneCoordsToViewport(vLine.x, 0);
// 遍历绘制点
for (const y_ of vLine.ys) {
// TODO: optimize
const {
y } = this.editor.sceneCoordsToViewport(0, y_);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
// 可能有重复的点,用备忘录排除掉
const key = `${
x},${
y}`;
if (pointsSet.has(key)) {
continue;
}
pointsSet.add(key);
// 绘制点
drawXShape(ctx, x, y, pointSize);
}
// 所有点中的 minY 和 maxY,绘制线段
drawLine(ctx, x, minY, x, maxY);
}
水平線も同様です。
最適化ポイント
- ここでの実装では、グラフィックスに回転角度がある場合、参照線が多すぎると冗長に見えるため、比較する参照線の数を減らすために簡略化できます。
- ピクセル グリッドに合わせるときは、境界ボックスの値を丸める必要があります。
- Shift キーを押したままにして x または y の移動を修正する場合を考えてみましょう。
やっと
要約すると、基準線吸着の実装は、最も近い垂直線と水平線を見つけ、offsetX と offsetY を計算し、移動したグラフィックスの x と y を修正し、最終的に重なった基準線を記録して描画することです。
私はフロントエンドのスイカ兄弟です。私をフォローして、グラフィック エディターについて詳しく学んでください。