243 lines
4.9 KiB
JavaScript
243 lines
4.9 KiB
JavaScript
/**
|
|
* Trace logic adapted from quick-connections <https://github.com/niknah/quick-connections>
|
|
*/
|
|
|
|
const EPSILON = 1e-6;
|
|
const INSIDE = 1;
|
|
const DEFAULT_CELL_SIZE = 256;
|
|
|
|
function clipT(num, denom, c) {
|
|
const tE = c[0], tL = c[1];
|
|
if (Math.abs(denom) < EPSILON) {
|
|
return num < 0;
|
|
}
|
|
const t = num / denom;
|
|
if (denom > 0) {
|
|
if (t > tL) {
|
|
return 0;
|
|
}
|
|
if (t > tE) {
|
|
c[0] = t;
|
|
}
|
|
} else {
|
|
if (t < tE) {
|
|
return 0;
|
|
}
|
|
if (t < tL) {
|
|
c[1] = t;
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
function liangBarsky(a, b, box, da, db) {
|
|
const x1 = a[0], y1 = a[1];
|
|
const x2 = b[0], y2 = b[1];
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
if (da === undefined || db === undefined) {
|
|
da = a;
|
|
db = b;
|
|
} else {
|
|
da[0] = a[0];
|
|
da[1] = a[1];
|
|
db[0] = b[0];
|
|
db[1] = b[1];
|
|
}
|
|
if (Math.abs(dx) < EPSILON
|
|
&& Math.abs(dy) < EPSILON
|
|
&& x1 >= box[0]
|
|
&& x1 <= box[2]
|
|
&& y1 >= box[1]
|
|
&& y1 <= box[3]) {
|
|
return INSIDE;
|
|
}
|
|
const c = [0, 1];
|
|
if (clipT(box[0] - x1, dx, c)
|
|
&& clipT(x1 - box[2], -dx, c)
|
|
&& clipT(box[1] - y1, dy, c)
|
|
&& clipT(y1 - box[3], -dy, c)) {
|
|
const tE = c[0], tL = c[1];
|
|
if (tL < 1) {
|
|
db[0] = x1 + tL * dx;
|
|
db[1] = y1 + tL * dy;
|
|
}
|
|
if (tE > 0) {
|
|
da[0] += tE * dx;
|
|
da[1] += tE * dy;
|
|
}
|
|
return INSIDE;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function getAreaFromNode(node) {
|
|
if (!node) {
|
|
return null;
|
|
}
|
|
try {
|
|
if (node.getBounding) {
|
|
const area = new Float32Array(4);
|
|
node.getBounding(area);
|
|
return [
|
|
area[0],
|
|
area[1],
|
|
area[0] + area[2],
|
|
area[1] + area[3],
|
|
];
|
|
}
|
|
} catch (e) {
|
|
}
|
|
if (node.pos && node.size) {
|
|
return [
|
|
node.pos[0],
|
|
node.pos[1],
|
|
node.pos[0] + node.size[0],
|
|
node.pos[1] + node.size[1],
|
|
];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeBox(item, index) {
|
|
const node = item?.node || item;
|
|
const area = item?.area || getAreaFromNode(node);
|
|
if (!area) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: node?.id ?? item?.id ?? index,
|
|
node,
|
|
area: Array.from(area),
|
|
linesArea: Array.from(item?.linesArea || area),
|
|
source: item,
|
|
};
|
|
}
|
|
|
|
function cellRange(area, cellSize) {
|
|
return {
|
|
x1: Math.floor(area[0] / cellSize),
|
|
y1: Math.floor(area[1] / cellSize),
|
|
x2: Math.floor(area[2] / cellSize),
|
|
y2: Math.floor(area[3] / cellSize),
|
|
};
|
|
}
|
|
|
|
function cellKey(x, y) {
|
|
return `${x},${y}`;
|
|
}
|
|
|
|
function expandedArea(area, padding) {
|
|
return [
|
|
area[0] - padding,
|
|
area[1] - padding,
|
|
area[2] + padding,
|
|
area[3] + padding,
|
|
];
|
|
}
|
|
|
|
function segmentArea(from, to, padding) {
|
|
return [
|
|
Math.min(from[0], to[0]) - padding,
|
|
Math.min(from[1], to[1]) - padding,
|
|
Math.max(from[0], to[0]) + padding,
|
|
Math.max(from[1], to[1]) + padding,
|
|
];
|
|
}
|
|
|
|
export function buildNodeBBoxIndex(items, options = {}) {
|
|
const cellSize = options.cellSize || DEFAULT_CELL_SIZE;
|
|
const padding = options.padding || 0;
|
|
const boxes = [];
|
|
const grid = new Map();
|
|
|
|
(items || []).forEach((item, index) => {
|
|
const box = normalizeBox(item, index);
|
|
if (!box) {
|
|
return;
|
|
}
|
|
|
|
box.area = expandedArea(box.area, padding);
|
|
box.linesArea = expandedArea(box.linesArea, padding);
|
|
const boxIndex = boxes.push(box) - 1;
|
|
const range = cellRange(box.area, cellSize);
|
|
|
|
for (let x = range.x1; x <= range.x2; x++) {
|
|
for (let y = range.y1; y <= range.y2; y++) {
|
|
const key = cellKey(x, y);
|
|
let bucket = grid.get(key);
|
|
if (!bucket) {
|
|
bucket = new Set();
|
|
grid.set(key, bucket);
|
|
}
|
|
bucket.add(boxIndex);
|
|
}
|
|
}
|
|
});
|
|
|
|
return { cellSize, boxes, grid };
|
|
}
|
|
|
|
export function querySegmentCandidates(index, from, to, padding = 0) {
|
|
if (!index) {
|
|
return [];
|
|
}
|
|
|
|
const seen = new Set();
|
|
const candidates = [];
|
|
const range = cellRange(segmentArea(from, to, padding), index.cellSize);
|
|
|
|
for (let x = range.x1; x <= range.x2; x++) {
|
|
for (let y = range.y1; y <= range.y2; y++) {
|
|
const bucket = index.grid.get(cellKey(x, y));
|
|
if (!bucket) {
|
|
continue;
|
|
}
|
|
for (const boxIndex of bucket) {
|
|
if (seen.has(boxIndex)) {
|
|
continue;
|
|
}
|
|
seen.add(boxIndex);
|
|
candidates.push(index.boxes[boxIndex]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
export function findSegmentIntersections(index, from, to, options = {}) {
|
|
const padding = options.padding || 0;
|
|
const excludeNodeIds = options.excludeNodeIds || null;
|
|
const candidates = querySegmentCandidates(index, from, to, padding);
|
|
const hits = [];
|
|
|
|
for (const candidate of candidates) {
|
|
if (excludeNodeIds?.has?.(candidate.id) || excludeNodeIds?.has?.(String(candidate.id))) {
|
|
continue;
|
|
}
|
|
|
|
const clipA = [-1, -1];
|
|
const clipB = [-1, -1];
|
|
const clipped = liangBarsky(from, to, candidate.area, clipA, clipB);
|
|
if (clipped !== INSIDE) {
|
|
continue;
|
|
}
|
|
|
|
const centerX = candidate.area[0] + ((candidate.area[2] - candidate.area[0]) / 2);
|
|
const centerY = candidate.area[1] + ((candidate.area[3] - candidate.area[1]) / 2);
|
|
const dx = centerX - from[0];
|
|
const dy = centerY - from[1];
|
|
hits.push({
|
|
start: clipA,
|
|
end: clipB,
|
|
node: candidate.source || candidate,
|
|
distance: (dx * dx) + (dy * dy),
|
|
});
|
|
}
|
|
|
|
hits.sort((a, b) => a.distance - b.distance);
|
|
return hits;
|
|
}
|