openpencil/packages/core/src/snap.ts
Danila Poyarkov 6d1cec1606 Extract @open-pencil/core package
Move engine (scene-graph, renderer, layout, codec, kiwi) into
packages/core/ as a Bun workspace package. The app's src/engine/
files become thin re-export shims so all existing imports still work.

- Bun workspace with packages/core
- Zero DOM dependencies in core
- Headless-friendly CanvasKit init (locateFile option)
- Constants split: engine constants in core, UI constants in app
- All 76 unit tests pass, lint + typecheck clean, vite build works
2026-03-01 01:03:07 +03:00

170 lines
4.5 KiB
TypeScript

import type { SceneNode } from './scene-graph'
import type { Rect } from './types'
const SNAP_THRESHOLD = 5
export interface SnapGuide {
axis: 'x' | 'y'
position: number
from: number
to: number
}
export interface SnapResult {
dx: number
dy: number
guides: SnapGuide[]
}
function getEdges(node: SceneNode) {
if (node.rotation === 0) {
return {
left: node.x,
right: node.x + node.width,
centerX: node.x + node.width / 2,
top: node.y,
bottom: node.y + node.height,
centerY: node.y + node.height / 2
}
}
const cx = node.x + node.width / 2
const cy = node.y + node.height / 2
const rad = (node.rotation * Math.PI) / 180
const cos = Math.cos(rad)
const sin = Math.sin(rad)
const hw = node.width / 2
const hh = node.height / 2
const corners = [
{ x: cx + -hw * cos - -hh * sin, y: cy + -hw * sin + -hh * cos },
{ x: cx + hw * cos - -hh * sin, y: cy + hw * sin + -hh * cos },
{ x: cx + hw * cos - hh * sin, y: cy + hw * sin + hh * cos },
{ x: cx + -hw * cos - hh * sin, y: cy + -hw * sin + hh * cos }
]
let left = Infinity,
right = -Infinity,
top = Infinity,
bottom = -Infinity
for (const c of corners) {
left = Math.min(left, c.x)
right = Math.max(right, c.x)
top = Math.min(top, c.y)
bottom = Math.max(bottom, c.y)
}
return {
left,
right,
centerX: (left + right) / 2,
top,
bottom,
centerY: (top + bottom) / 2
}
}
export function computeSnap(
movingIds: Set<string>,
movingBounds: Rect,
allNodes: SceneNode[]
): SnapResult {
const targets = allNodes.filter((n) => !movingIds.has(n.id))
if (targets.length === 0) return { dx: 0, dy: 0, guides: [] }
const m = {
left: movingBounds.x,
right: movingBounds.x + movingBounds.width,
centerX: movingBounds.x + movingBounds.width / 2,
top: movingBounds.y,
bottom: movingBounds.y + movingBounds.height,
centerY: movingBounds.y + movingBounds.height / 2
}
let bestDx = Infinity
let bestDy = Infinity
const guides: SnapGuide[] = []
for (const target of targets) {
const t = getEdges(target)
// X-axis snapping: left-to-left, left-to-right, right-to-left, right-to-right, center-to-center
const xPairs: [number, number][] = [
[m.left, t.left],
[m.left, t.right],
[m.right, t.left],
[m.right, t.right],
[m.centerX, t.centerX]
]
for (const [mVal, tVal] of xPairs) {
const d = tVal - mVal
if (Math.abs(d) < SNAP_THRESHOLD && Math.abs(d) <= Math.abs(bestDx)) {
if (Math.abs(d) < Math.abs(bestDx)) {
bestDx = d
guides.length = guides.filter((g) => g.axis === 'y').length
? guides.length
: guides.length
// Remove old x guides if we found a closer snap
for (let i = guides.length - 1; i >= 0; i--) {
if (guides[i].axis === 'x') guides.splice(i, 1)
}
}
if (Math.abs(d) === Math.abs(bestDx)) {
const minY = Math.min(m.top, t.top)
const maxY = Math.max(m.bottom, t.bottom)
guides.push({ axis: 'x', position: tVal, from: minY, to: maxY })
}
}
}
// Y-axis snapping
const yPairs: [number, number][] = [
[m.top, t.top],
[m.top, t.bottom],
[m.bottom, t.top],
[m.bottom, t.bottom],
[m.centerY, t.centerY]
]
for (const [mVal, tVal] of yPairs) {
const d = tVal - mVal
if (Math.abs(d) < SNAP_THRESHOLD && Math.abs(d) <= Math.abs(bestDy)) {
if (Math.abs(d) < Math.abs(bestDy)) {
for (let i = guides.length - 1; i >= 0; i--) {
if (guides[i].axis === 'y') guides.splice(i, 1)
}
bestDy = d
}
if (Math.abs(d) === Math.abs(bestDy)) {
const minX = Math.min(m.left, t.left)
const maxX = Math.max(m.right, t.right)
guides.push({ axis: 'y', position: tVal, from: minX, to: maxX })
}
}
}
}
return {
dx: Math.abs(bestDx) <= SNAP_THRESHOLD ? bestDx : 0,
dy: Math.abs(bestDy) <= SNAP_THRESHOLD ? bestDy : 0,
guides
}
}
export function computeSelectionBounds(nodes: SceneNode[]): Rect | null {
if (nodes.length === 0) return null
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
const edges = getEdges(n)
minX = Math.min(minX, edges.left)
minY = Math.min(minY, edges.top)
maxX = Math.max(maxX, edges.right)
maxY = Math.max(maxY, edges.bottom)
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}