Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to IPD Studio. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.16.0] — 2026-09-01 — magnetic docking

### Added

- **Magnetic docking** — connect by touching instead of by drawing. Drag a
symbol (in from the palette, or one already on the sheet) so one of its
connection points comes within ~18 screen px of another symbol's, and a
ring marks the point it has caught while every connection point on the
sheet lights up. Let go and the symbol clicks into place — the two points
land on exactly the same spot — with the line already drawn between them.
Because lines store ports and not coordinates, pulling the pair apart
afterwards stretches the pipe instead of breaking it.
- The line class is chosen from the two port kinds, so a controller docking
onto a valve's signal boss gets `signal.electric` even with a process
class selected in the toolbar; incompatible pairings never dock.
- The symbol and the line it docked onto are ONE undo step, whether they
arrived from the palette (`addBatch`) or from a drag (`dockNode`).
- A pair that is already joined won't dock again, so nudging a docked
symbol can't stack a second line on top of the first.

## [0.15.0] — 2026-09-01 — the engineering registry

Second step of the engineering-platform plan
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ P&ID tool is a $2,600+/year desktop install. IPD Studio is the missing thing:
- **Works offline** — installable PWA; the whole editor runs with no internet
- **Editor power** — Ctrl+F find-any-tag across sheets, align/distribute,
live snap guides, print-all-sheets PDF, line sequence auto-numbering
- **Magnetic docking** — drag a symbol so its connection point touches
another symbol's and let go: it clicks into place already piped up. Pull
them apart and the line stretches to follow
- **Obstacle-avoiding orthogonal routing** with draggable waypoints, ports
with connection rules (a pneumatic signal won't connect to a pipe nozzle)
- **Live validation**: duplicate tags, missing tags, illegal ISA letters,
Expand Down
141 changes: 141 additions & 0 deletions e2e/autoconnect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { expect, test, type Page } from '@playwright/test'

/* Magnetic docking — connect by touching, not by drawing:
- drop a palette symbol onto an existing symbol's connection point and it
clicks into place already connected
- drag a symbol already on the sheet the same way, in one undo step
- pull the pair apart afterwards and the pipe stretches instead of breaking */

interface PidHook {
useStore: { getState(): any; temporal: { getState(): any } }
canvasRef: { paper?: any }
}
declare global {
interface Window { __pid: PidHook }
}

async function clientPoint(page: Page, x: number, y: number): Promise<{ x: number; y: number }> {
return page.evaluate(([lx, ly]) => {
const p = window.__pid.canvasRef.paper!.localToClientPoint({ x: lx, y: ly })
return { x: p.x, y: p.y }
}, [x, y])
}

async function drag(page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 16) {
await page.mouse.move(from.x, from.y)
await page.mouse.down()
await page.mouse.move(to.x, to.y, { steps })
await page.mouse.up()
}

const sheet = (page: Page) => page.evaluate(() => window.__pid.useStore.getState().doc.sheets[0])
const undoDepth = (page: Page) =>
page.evaluate(() => window.__pid.useStore.temporal.getState().pastStates.length)

/** Drop a palette symbol on the canvas at a sheet point, the way the browser
* does it: a real DragEvent carrying the palette's MIME payload. */
async function dropSymbol(page: Page, symbolId: string, at: { x: number; y: number }) {
const client = await clientPoint(page, at.x, at.y)
await page.evaluate(
([id, cx, cy]) => {
const dt = new DataTransfer()
dt.setData('application/x-pid-symbol', JSON.stringify({ symbolId: id }))
const host = document.querySelector('[data-testid="canvas"]')!
host.dispatchEvent(
new DragEvent('drop', { dataTransfer: dt, clientX: cx as number, clientY: cy as number, bubbles: true, cancelable: true }),
)
},
[symbolId, client.x, client.y] as [string, number, number],
)
}

/** A single gate valve at (200,200): 32x16, port w at (200,208), e at (232,208). */
async function withGateValve(page: Page): Promise<string> {
await page.goto('/app')
await page.waitForFunction(() => '__pid' in window)
await page.waitForFunction(() => Boolean(window.__pid))
const id = await page.evaluate(() => {
const s = window.__pid.useStore.getState()
const id = s.addNode({ symbolId: 'valve.gate', kind: 'valve', x: 200, y: 200, rotation: 0 })
s.setSelection([])
return id as string
})
await expect(page.locator('[model-id]')).toHaveCount(1)
return id
}

test('dropping a palette symbol on a connection point docks and connects it', async ({ page }) => {
const gate = await withGateValve(page)

// Aim so the dropped valve's own w port lands 8px off the fixed valve's e
// port: centred on (254,210) it is placed at (240,200), w at (240,208).
await dropSymbol(page, 'valve.gate', { x: 254, y: 210 })

await expect.poll(async () => (await sheet(page)).nodes.length).toBe(2)
const after = await sheet(page)
const dropped = after.nodes.find((n: any) => n.id !== gate)

// pulled into place so the two connection points are the same point
expect({ x: dropped.x, y: dropped.y }).toEqual({ x: 232, y: 200 })
expect(after.edges).toHaveLength(1)
expect(after.edges[0].source).toEqual({ nodeId: dropped.id, portId: 'w' })
expect(after.edges[0].target).toEqual({ nodeId: gate, portId: 'e' })
expect(after.edges[0].lineClass).toBe('process.major')

// symbol + line arrived together, so one undo takes both back
await page.evaluate(() => window.__pid.useStore.getState().undo())
const undone = await sheet(page)
expect(undone.nodes).toHaveLength(1)
expect(undone.edges).toHaveLength(0)
})

test('a symbol dropped clear of every connection point just lands there', async ({ page }) => {
await withGateValve(page)
await dropSymbol(page, 'valve.gate', { x: 500, y: 500 })
await expect.poll(async () => (await sheet(page)).nodes.length).toBe(2)
expect((await sheet(page)).edges).toHaveLength(0)
})

test('dragging a placed symbol onto a connection point docks it, and pulling away stretches the line', async ({ page }) => {
const gate = await withGateValve(page)
const moving = await page.evaluate(() => {
const s = window.__pid.useStore.getState()
const id = s.addNode({ symbolId: 'valve.gate', kind: 'valve', x: 400, y: 300, rotation: 0 })
s.setSelection([])
return id as string
})
await expect(page.locator('[model-id]')).toHaveCount(2)
const before = await undoDepth(page)

// Grab the moving valve by its centre (416,308) and bring its w port to
// within a few px of the fixed valve's e port at (232,208).
await drag(page, await clientPoint(page, 416, 308), await clientPoint(page, 254, 208))

await expect.poll(async () => (await sheet(page)).edges.length).toBe(1)
const docked = await sheet(page)
const node = docked.nodes.find((n: any) => n.id === moving)
expect({ x: node.x, y: node.y }).toEqual({ x: 232, y: 200 })
expect(docked.edges[0].source).toEqual({ nodeId: moving, portId: 'w' })
expect(docked.edges[0].target).toEqual({ nodeId: gate, portId: 'e' })
// the move and the line are one gesture, so they are one undo step
expect(await undoDepth(page)).toBe(before + 1)

// docked: the ports coincide, so there is no pipe drawn between them yet
const edgeId = docked.edges[0].id
const length = () =>
page.evaluate((id) => {
const paper = window.__pid.canvasRef.paper!
const view = paper.model.getCell(id).findView(paper)
return view.getConnectionLength() as number
}, edgeId)
await expect.poll(length).toBeLessThan(2)

// pull it away and the pipe stretches to follow instead of breaking
await drag(page, await clientPoint(page, 248, 208), await clientPoint(page, 448, 208))
const moved = await sheet(page)
expect(moved.edges).toHaveLength(1)
expect(moved.edges[0].source).toEqual({ nodeId: moving, portId: 'w' })
expect(moved.edges[0].target).toEqual({ nodeId: gate, portId: 'e' })
expect(moved.nodes.find((n: any) => n.id === moving).x).toBeGreaterThan(400)
await expect.poll(length).toBeGreaterThan(150)
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ipd-studio",
"private": true,
"version": "0.15.0",
"version": "0.16.0",
"license": "PolyForm-Noncommercial-1.0.0",
"type": "module",
"scripts": {
Expand Down
18 changes: 18 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,24 @@ button { font: inherit; }
.pid-port-hit:hover { fill: rgba(43, 108, 176, 0.22); stroke: #2b6cb0; }
/* While dragging a link, every legal target port shows a slim ring. */
.pid-port-hit.available-magnet { fill: rgba(43, 108, 176, 0.12); stroke: #2b6cb0; stroke-width: 1.25; r: 5px; }
/* Magnetic docking: dragging a symbol (from the palette or across the sheet)
raises every connection dot, and a ring marks the point it will click onto
and connect to when the drag is released. */
.pid-docking .pid-port-dot { opacity: 1; }
.pid-dock-hint {
fill: rgba(43, 108, 176, 0.2);
stroke: #2b6cb0;
stroke-width: 1.75;
pointer-events: none;
animation: pid-dock-pulse 1.1s ease-in-out infinite;
}
@keyframes pid-dock-pulse {
50% { fill: rgba(43, 108, 176, 0.38); r: 8.5px; }
}
/* The ring still marks the spot, it just stops pulsing. */
@media (prefers-reduced-motion: reduce) {
.pid-dock-hint { animation: none; }
}

/* --- quick line editor ---------------------------------------------------- */
.line-popover {
Expand Down
174 changes: 174 additions & 0 deletions src/canvas/autoConnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only;
// commercial use requires a paid license (see COMMERCIAL-LICENSE.md).

/**
* Magnetic docking — connect by touching, not by drawing.
*
* Bring a symbol's connection point close to another symbol's connection
* point and let go: the symbol clicks into place so the two points coincide
* exactly, and the line between them is created for you. Because the line
* stores PORTS and not coordinates, pulling the symbols apart afterwards
* stretches the pipe instead of breaking it.
*
* Used from two places, one gesture each:
* - dropHandling.ts — a symbol dragged in from the palette
* - interactions.ts — a symbol already on the sheet, dragged by hand
*/

import type { dia } from '@joint/core'
import type { LineClass, PlantEdge, PlantNode } from '../model/types'
import { isPortEnd } from '../model/types'
import type { PortKind } from '../symbols/types'
import { getSymbol } from '../symbols/registry'
import { portWorld } from './alignment'
import { compatibleKinds, pickLineClass } from './connectionRules'

/**
* How near a port has to come before it docks, in SCREEN px — the distance is
* judged by what the user sees, so the reach feels the same zoomed in or out.
* Clamped in sheet space so an extreme zoom can't make it either unhittable
* or grabby enough to swallow neighbouring symbols.
*/
export const DOCK_SCREEN_PX = 18
const MIN_SHEET_RADIUS = 6
const MAX_SHEET_RADIUS = 48

export function dockRadius(scale: number): number {
const s = scale > 0 ? scale : 1
return Math.min(MAX_SHEET_RADIUS, Math.max(MIN_SHEET_RADIUS, DOCK_SCREEN_PX / s))
}

export interface Dock {
/** Port on the symbol being moved. */
movingPortId: string
targetNodeId: string
targetPortId: string
/** Where the moving symbol has to sit for the two ports to coincide. */
x: number
y: number
/** Sheet point the ports meet at — where the hint ring is drawn. */
at: { x: number; y: number }
lineClass: LineClass
}

interface PortRef {
nodeId: string
portId: string
kind: PortKind
x: number
y: number
}

/** Catalog ports plus any user-added pins. Unknown symbols have none. */
function portsOf(node: PlantNode): { id: string; kind: PortKind }[] {
try {
return [...getSymbol(node.symbolId).ports, ...(node.extraPorts ?? [])].map((p) => ({
id: p.id,
kind: p.kind,
}))
} catch {
return []
}
}

const endKey = (end: PlantEdge['source']): string =>
isPortEnd(end) ? `${end.nodeId}/${end.portId}` : ''

/**
* The best port pairing for `moving` at its current x/y, or null when nothing
* is in reach. `moving` may be a node that does not exist on the sheet yet
* (a palette drag in flight); `others` is simply scanned for a different id.
*
* Pairs that are already joined are skipped, so nudging a symbol that is
* docked doesn't stack a second identical line on top of the first.
*/
export function findDock(
moving: PlantNode,
others: PlantNode[],
edges: PlantEdge[],
activeLineClass: LineClass,
radius: number,
): Dock | null {
const mine = portsOf(moving)
if (!mine.length) return null

const joined = new Set<string>()
for (const e of edges) {
const a = endKey(e.source)
const b = endKey(e.target)
if (a && b) {
joined.add(`${a}|${b}`)
joined.add(`${b}|${a}`)
}
}

// Resolve every candidate port once, not once per port of the moving symbol.
const targets: PortRef[] = []
for (const other of others) {
if (other.id === moving.id) continue
for (const p of portsOf(other)) {
const at = portWorld(other, p.id)
if (at) targets.push({ nodeId: other.id, portId: p.id, kind: p.kind, x: at.x, y: at.y })
}
}

let best: Dock | null = null
let bestDistance = radius
for (const mp of mine) {
const from = portWorld(moving, mp.id)
if (!from) continue
for (const t of targets) {
const d = Math.hypot(t.x - from.x, t.y - from.y)
if (d > bestDistance) continue
if (!compatibleKinds(mp.kind, t.kind)) continue
if (joined.has(`${moving.id}/${mp.id}|${t.nodeId}/${t.portId}`)) continue
bestDistance = d
best = {
movingPortId: mp.id,
targetNodeId: t.nodeId,
targetPortId: t.portId,
x: Math.round(moving.x + t.x - from.x),
y: Math.round(moving.y + t.y - from.y),
at: { x: t.x, y: t.y },
lineClass: pickLineClass(mp.kind, t.kind, activeLineClass),
}
}
}
return best
}

/** The edge a dock creates, ready for addBatch/dockNode. */
export function dockEdge(movingId: string, dock: Dock): Omit<PlantEdge, 'id'> {
return {
lineClass: dock.lineClass,
source: { nodeId: movingId, portId: dock.movingPortId },
target: { nodeId: dock.targetNodeId, portId: dock.targetPortId },
}
}

const HINT_CLASS = 'pid-dock-hint'
const NS = 'http://www.w3.org/2000/svg'

/**
* Ring at the point the drag will dock onto — the visible promise that
* letting go connects. Lives inside `.joint-layers`, the group carrying the
* pan/zoom transform, so it sits on the sheet rather than on the viewport.
* Passing null takes it down.
*/
export function showDockHint(paper: dia.Paper, at: { x: number; y: number } | null): void {
const layer = paper.svg.querySelector('.joint-layers')
if (!layer) return
const existing = layer.querySelector(`.${HINT_CLASS}`) as SVGCircleElement | null
if (!at) {
existing?.remove()
return
}
const ring = existing ?? document.createElementNS(NS, 'circle')
ring.setAttribute('class', HINT_CLASS)
ring.setAttribute('r', '7')
ring.setAttribute('cx', String(at.x))
ring.setAttribute('cy', String(at.y))
ring.setAttribute('pointer-events', 'none')
if (!existing) layer.appendChild(ring)
}
Loading
Loading