obsidian-visualiser/shared/physics.util.ts

369 lines
13 KiB
TypeScript

import type { CanvasNode } from "~/types/canvas";
import type { CanvasPreferences } from "~/types/general";
import type { Position, Box, Direction, CanvasEditor } from "./canvas.util";
interface SnapPoint {
pos: Position;
type: TYPE;
side?: Direction;
}
export interface SnapHint {
start: Position,
end?: Position,
}
interface SnapConfig {
preferences: CanvasPreferences;
threshold: number;
cellSize: number;
gridSize: number;
}
const enum TYPE {
CENTER,
CORNER,
EDGE,
}
export function overlapsBoxes(a: Box, b: Box): boolean
{
return overlaps(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height);
}
export function overlaps(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean
{
return !(bx > (ax + aw)
|| (bx + bw) < ax
|| by > (ay + ah)
|| (by + bh) < ay);
}
export class SpatialGrid<T extends Box> {
private cells: Map<number, Map<number, Set<T>>> = new Map();
private cellSize: number;
private minx: number = Infinity;
private miny: number = Infinity;
private maxx: number = -Infinity;
private maxy: number = -Infinity;
private cacheSet: Set<T> = new Set<T>();
constructor(cellSize: number)
{
this.cellSize = cellSize;
}
private updateBorders(startX: number, startY: number, endX: number, endY: number)
{
this.minx = Math.min(this.minx, startX);
this.miny = Math.min(this.miny, startY);
this.maxx = Math.max(this.maxx, endX);
this.maxy = Math.max(this.maxy, endY);
}
insert(node: T): void
{
const startX = Math.floor(node.x / this.cellSize);
const startY = Math.floor(node.y / this.cellSize);
const endX = Math.ceil((node.x + node.width) / this.cellSize);
const endY = Math.ceil((node.y + node.height) / this.cellSize);
this.updateBorders(startX, startY, endX, endY);
for (let i = startX; i <= endX; i++) {
let gridX = this.cells.get(i);
if (!gridX) {
gridX = new Map<number, Set<T>>();
this.cells.set(i, gridX);
}
for (let j = startY; j <= endY; j++) {
let gridY = gridX.get(j);
if (!gridY) {
gridY = new Set<T>();
gridX.set(j, gridY);
}
gridY.add(node);
}
}
}
remove(node: T): void {
const startX = Math.floor(node.x / this.cellSize);
const startY = Math.floor(node.y / this.cellSize);
const endX = Math.ceil((node.x + node.width) / this.cellSize);
const endY = Math.ceil((node.y + node.height) / this.cellSize);
for (let i = startX; i <= endX; i++) {
const gridX = this.cells.get(i);
if (gridX) {
for (let j = startY; j <= endY; j++) {
gridX.get(j)?.delete(node);
}
}
}
}
fetch(x: number, y: number): Set<T> | undefined {
return this.query(x, y, x, y);
}
query(x1: number, y1: number, x2: number, y2: number): Set<T> {
this.cacheSet.clear();
const startX = Math.floor(x1 / this.cellSize);
const startY = Math.floor(y1 / this.cellSize);
const endX = Math.ceil(x2 / this.cellSize);
const endY = Math.ceil(y2 / this.cellSize);
for (let dx = startX; dx <= endX; dx++) {
const gridX = this.cells.get(dx);
if (gridX) {
for (let dy = startY; dy <= endY; dy++) {
const cellNodes = gridX.get(dy);
if (cellNodes) {
cellNodes.forEach(neighbor => !this.cacheSet.has(neighbor) && (overlaps(x1, y1, x2 - x1, y2 - y1, neighbor.x, neighbor.y, neighbor.width, neighbor.height)) && this.cacheSet.add(neighbor));
}
}
}
}
return this.cacheSet;
}
getViewportNeighbors(node: T, viewport?: Box): Set<T> {
this.cacheSet.clear();
const startX = Math.floor(node.x / this.cellSize);
const startY = Math.floor(node.y / this.cellSize);
const endX = Math.ceil((node.x + node.width) / this.cellSize);
const endY = Math.ceil((node.y + node.height) / this.cellSize);
const minX = Math.max(viewport ? Math.max(this.minx, Math.floor(viewport.x / this.cellSize)) : this.minx, startX - 8);
const minY = Math.max(viewport ? Math.max(this.miny, Math.floor(viewport.y / this.cellSize)) : this.miny, startY - 8);
const maxX = Math.min(viewport ? Math.min(this.maxx, Math.ceil((viewport.x + viewport.width) / this.cellSize)) : this.maxx, endX + 8);
const maxY = Math.min(viewport ? Math.min(this.maxy, Math.ceil((viewport.y + viewport.height) / this.cellSize)) : this.maxy, endY + 8);
for (let dx = minX; dx <= maxX; dx++) {
const gridX = this.cells.get(dx);
if (gridX) {
for (let dy = startY; dy <= endY; dy++) {
const cellNodes = gridX.get(dy);
if (cellNodes) {
cellNodes.forEach(neighbor => {
if (neighbor !== node) this.cacheSet.add(neighbor);
});
}
}
}
}
for (let dx = startX; dx <= endX; dx++) {
const gridX = this.cells.get(dx);
if (gridX) {
for (let dy = minY; dy <= maxY; dy++) {
const cellNodes = gridX.get(dy);
if (cellNodes) {
cellNodes.forEach(neighbor => {
if (neighbor !== node) this.cacheSet.add(neighbor);
});
}
}
}
}
return this.cacheSet;
}
}
class SnapPointCache {
private cache: Map<string, SnapPoint[]>;
constructor() {
this.cache = new Map();
}
getSnapPoints(node: string): SnapPoint[] | undefined {
return this.cache.get(node);
}
private calculateSnapPoints(node: CanvasNode): SnapPoint[] {
const centerX = node.x + node.width / 2;
const centerY = node.y + node.height / 2;
return [
{ pos: { x: centerX, y: centerY }, type: TYPE.CENTER },
{ pos: { x: node.x, y: node.y }, type: TYPE.CORNER },
{ pos: { x: node.x + node.width, y: node.y }, type: TYPE.CORNER },
{ pos: { x: node.x, y: node.y + node.height }, type: TYPE.CORNER },
{ pos: { x: node.x + node.width, y: node.y + node.height }, type: TYPE.CORNER },
{ pos: { x: centerX, y: node.y }, type: TYPE.EDGE, side: 'top' },
{ pos: { x: node.x, y: centerY }, type: TYPE.EDGE, side: 'left' },
{ pos: { x: centerX, y: node.y + node.height }, type: TYPE.EDGE, side: 'bottom' },
{ pos: { x: node.x + node.width, y: centerY }, type: TYPE.EDGE, side: 'right' },
];
}
insert(node: CanvasNode): void {
if (!this.cache.has(node.id)) {
this.cache.set(node.id, this.calculateSnapPoints(node));
}
}
invalidate(node: CanvasNode): void {
this.cache.delete(node.id);
}
}
export class SnapFinder {
private spatialGrid: SpatialGrid<CanvasNode>;
private snapPointCache: SnapPointCache;
private config: SnapConfig;
private canvas: CanvasEditor;
private hints: SnapHint[] = [];
constructor(canvas: CanvasEditor, config: SnapConfig) {
this.spatialGrid = new SpatialGrid(config.cellSize);
this.snapPointCache = new SnapPointCache();
this.config = config;
this.canvas = canvas;
}
add(node: CanvasNode): void
{
this.spatialGrid.insert(node);
this.snapPointCache.insert(node);
this.hints.length = 0;
}
remove(node: CanvasNode): void
{
this.spatialGrid.remove(node);
this.snapPointCache.invalidate(node);
this.hints.length = 0;
}
update(node: CanvasNode): void
{
this.remove(node);
this.add(node);
}
findEdgeSnapPosition(node: string, x: number, y: number): { x: number, y: number, node: string, direction: Direction } | undefined
{
const near = [...this.spatialGrid.fetch(x, y)?.values().filter(e => e.id !== node).flatMap(e => this.snapPointCache.getSnapPoints(e.id)?.map(_e => ({ ..._e, node: e })) ?? []) ?? []].filter(e => e.type === TYPE.EDGE);
let nearestDistance = this.config.threshold, nearest = undefined;
for (const point of near) {
const distance = Math.hypot(point.pos.x - x, point.pos.y - y);
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = { ...point.pos, node: point.node.id, direction: point.side! };
}
}
return nearest;
}
findNodeSnapPosition(node: CanvasNode, resizeHandle?: Box): Partial<Box>
{
const result: Partial<Box> = {
x: undefined,
y: undefined,
width: undefined,
height: undefined,
};
if(!this.config.preferences.neighborSnap)
{
result.x = this.snapToGrid(node.x);
result.width = this.snapToGrid(node.width);
result.y = this.snapToGrid(node.y);
result.height = this.snapToGrid(node.height);
return result;
}
this.hints.length = 0;
this.snapPointCache.invalidate(node);
this.snapPointCache.insert(node);
const neighbors = [...this.spatialGrid.getViewportNeighbors(node, this.canvas.viewport)].flatMap(e => this.snapPointCache.getSnapPoints(e.id)).filter(e => !!e);
const bestSnap = this.findBestSnap(this.snapPointCache.getSnapPoints(node.id)!, neighbors, this.config.threshold, resizeHandle);
return this.applySnap(node, bestSnap.x, bestSnap.y, resizeHandle);
}
private findBestSnap(activePoints: SnapPoint[], otherPoints: SnapPoint[], threshold: number, resizeHandle?: Box): Partial<Position>
{
let bestSnap: Partial<Position> = {};
let bestDiffX = threshold, bestDiffY = threshold;
let xHints: SnapHint[] = [], yHints: SnapHint[] = [];
for (const activePoint of activePoints) {
if (activePoint.type === TYPE.EDGE) continue;
if (!!resizeHandle && activePoint.type !== TYPE.CORNER) continue;
for (const otherPoint of otherPoints) {
if (otherPoint.type === TYPE.EDGE) continue;
if (!!resizeHandle && otherPoint.type !== TYPE.CORNER) continue;
const diffX = Math.abs(otherPoint.pos.x - activePoint.pos.x);
const diffY = Math.abs(otherPoint.pos.y - activePoint.pos.y);
if (diffX < bestDiffX) {
bestDiffX = diffX;
bestSnap.x = otherPoint.pos.x - activePoint.pos.x;
xHints = [{ start: { x: otherPoint.pos.x, y: activePoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } }];
} else if(diffX === bestDiffX) {
xHints.push({ start: { x: otherPoint.pos.x, y: activePoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } });
}
if (diffY < bestDiffY) {
bestDiffY = diffY;
bestSnap.y = otherPoint.pos.y - activePoint.pos.y;
yHints = [{ start: { x: activePoint.pos.x, y: otherPoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } }];
} else if(diffY === bestDiffY) {
yHints.push({ start: { x: activePoint.pos.x, y: otherPoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } });
}
}
}
if(bestSnap.x && bestSnap.y)
{
xHints.forEach(e => e.start.y += bestSnap.y!);
yHints.forEach(e => e.start.x += bestSnap.x!);
}
this.hints = [...xHints, ...yHints];
return bestSnap;
}
private snapToGrid(pos?: number): number | undefined
{
return pos && this.config.preferences.gridSnap && this.config.preferences.spacing ? Math.round(pos / this.config.preferences.spacing) * this.config.preferences.spacing : undefined;
}
private applySnap(node: CanvasNode, offsetx?: number, offsety?: number, resizeHandle?: Box): Partial<Box>
{
const result: Partial<Box> = { x: undefined, y: undefined, width: undefined, height: undefined };
if (resizeHandle)
{
result.x = offsetx ? node.x + offsetx * resizeHandle.x : this.snapToGrid(node.x);
result.width = offsetx ? node.width + offsetx * resizeHandle.width : this.snapToGrid(node.width);
result.y = offsety ? node.y + offsety * resizeHandle.y : this.snapToGrid(node.y);
result.height = offsety ? node.height - offsety * resizeHandle.height : this.snapToGrid(node.height);
}
else
{
result.x = offsetx ? node.x + offsetx : this.snapToGrid(node.x);
result.y = offsety ? node.y + offsety : this.snapToGrid(node.y);
}
return result;
}
}