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
170 lines
4.5 KiB
TypeScript
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 }
|
|
}
|