feat: add interactive edge waypoints with smooth path editing
- Drag waypoints to reshape edges; double-click a waypoint to remove it - + handles at segment midpoints to insert new waypoints - Bezier style: catmull-rom smooth curves through waypoints - Smooth style: rounded-corner polyline with soft 45° snap (snaps within 15px) - First + handle biased to source axis for perpendicular node exit - snap45both: ray-intersection solver ensures both adjacent segments snap to 45° simultaneously - Clear path button in EdgeModal when waypoints exist - Waypoints serialised/deserialised with canvas state
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import type { Waypoint } from '@/types'
|
||||
|
||||
// ── Path builders ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Catmull-Rom → cubic bezier for smooth curves through waypoints */
|
||||
function buildCatmullRomPath(pts: Waypoint[]): string {
|
||||
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(i - 1, 0)]
|
||||
const p1 = pts[i]
|
||||
const p2 = pts[i + 1]
|
||||
const p3 = pts[Math.min(i + 2, pts.length - 1)]
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6
|
||||
d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/** Polyline with rounded corners at each waypoint vertex (quadratic bezier) */
|
||||
function buildRoundedPolylinePath(pts: Waypoint[], radius = 8): string {
|
||||
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
|
||||
if (pts.length === 2) return `M ${pts[0].x} ${pts[0].y} L ${pts[1].x} ${pts[1].y}`
|
||||
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`
|
||||
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const prev = pts[i - 1]
|
||||
const curr = pts[i]
|
||||
const next = pts[i + 1]
|
||||
|
||||
const dx1 = curr.x - prev.x
|
||||
const dy1 = curr.y - prev.y
|
||||
const len1 = Math.hypot(dx1, dy1)
|
||||
|
||||
const dx2 = next.x - curr.x
|
||||
const dy2 = next.y - curr.y
|
||||
const len2 = Math.hypot(dx2, dy2)
|
||||
|
||||
if (len1 < 1 || len2 < 1) {
|
||||
d += ` L ${curr.x} ${curr.y}`
|
||||
continue
|
||||
}
|
||||
|
||||
const r = Math.min(radius, len1 / 2, len2 / 2)
|
||||
|
||||
// Approach point (on segment prev→curr, r units before corner)
|
||||
const bx = curr.x - (dx1 / len1) * r
|
||||
const by = curr.y - (dy1 / len1) * r
|
||||
|
||||
// Departure point (on segment curr→next, r units after corner)
|
||||
const ax = curr.x + (dx2 / len2) * r
|
||||
const ay = curr.y + (dy2 / len2) * r
|
||||
|
||||
d += ` L ${bx} ${by} Q ${curr.x} ${curr.y} ${ax} ${ay}`
|
||||
}
|
||||
|
||||
d += ` L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`
|
||||
return d
|
||||
}
|
||||
|
||||
export function buildWaypointPath(
|
||||
sourceX: number, sourceY: number,
|
||||
waypoints: Waypoint[],
|
||||
targetX: number, targetY: number,
|
||||
pathStyle: string = 'bezier',
|
||||
): string {
|
||||
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
||||
}
|
||||
|
||||
// ── 45° snapping ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Snap `pos` to the nearest 45°-multiple direction from `from`.
|
||||
* Only snaps when within SNAP_THRESHOLD px of a 45° position.
|
||||
*/
|
||||
export function snap45(from: Waypoint, pos: Waypoint): Waypoint {
|
||||
const dx = pos.x - from.x
|
||||
const dy = pos.y - from.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if (dist < 1) return pos
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
|
||||
const candidate = {
|
||||
x: Math.round(from.x + dist * Math.cos(snapped)),
|
||||
y: Math.round(from.y + dist * Math.sin(snapped)),
|
||||
}
|
||||
const deviation = Math.hypot(candidate.x - pos.x, candidate.y - pos.y)
|
||||
return deviation <= SNAP_THRESHOLD ? candidate : pos
|
||||
}
|
||||
|
||||
/** Snap threshold in flow-space pixels. Only snap when this close to a 45° position. */
|
||||
const SNAP_THRESHOLD = 15
|
||||
|
||||
/**
|
||||
* Find the position closest to `pos` that lies simultaneously on a 45°-ray
|
||||
* from `prev` AND on a 45°-ray from `next`.
|
||||
*
|
||||
* Only snaps when the nearest valid intersection is within SNAP_THRESHOLD px —
|
||||
* outside that zone the raw drag position is returned, allowing free placement.
|
||||
*/
|
||||
export function snap45both(prev: Waypoint, next: Waypoint, pos: Waypoint): Waypoint {
|
||||
let best: Waypoint | null = null
|
||||
let bestDist = Infinity
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a1 = i * Math.PI / 4
|
||||
const c1 = Math.cos(a1), s1 = Math.sin(a1)
|
||||
|
||||
for (let j = 0; j < 8; j++) {
|
||||
const a2 = j * Math.PI / 4
|
||||
const c2 = Math.cos(a2), s2 = Math.sin(a2)
|
||||
|
||||
const dx = next.x - prev.x
|
||||
const dy = next.y - prev.y
|
||||
const det = -c1 * s2 + c2 * s1
|
||||
if (Math.abs(det) < 1e-6) continue
|
||||
|
||||
const t = (-dx * s2 + c2 * dy) / det
|
||||
const s = (c1 * dy - s1 * dx) / det
|
||||
if (t < -1e-6 || s < -1e-6) continue
|
||||
|
||||
const ix = prev.x + t * c1
|
||||
const iy = prev.y + t * s1
|
||||
const d = Math.hypot(ix - pos.x, iy - pos.y)
|
||||
if (d < bestDist) {
|
||||
bestDist = d
|
||||
best = { x: Math.round(ix), y: Math.round(iy) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only snap if close enough — otherwise let the waypoint move freely
|
||||
if (best === null || bestDist > SNAP_THRESHOLD) return pos
|
||||
return best
|
||||
}
|
||||
|
||||
// ── Geometry helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
export function distToSegment(p: Waypoint, a: Waypoint, b: Waypoint): number {
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const lenSq = dx * dx + dy * dy
|
||||
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y)
|
||||
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq))
|
||||
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
|
||||
}
|
||||
|
||||
export function findInsertIndex(
|
||||
sourceX: number, sourceY: number,
|
||||
waypoints: Waypoint[],
|
||||
targetX: number, targetY: number,
|
||||
point: Waypoint,
|
||||
): number {
|
||||
const allPts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||
let minDist = Infinity
|
||||
let best = 0
|
||||
for (let i = 0; i < allPts.length - 1; i++) {
|
||||
const d = distToSegment(point, allPts[i], allPts[i + 1])
|
||||
if (d < minDist) { minDist = d; best = i }
|
||||
}
|
||||
return best
|
||||
}
|
||||
Reference in New Issue
Block a user