Skip to content

Commit ceefc82

Browse files
authored
Merge pull request #337 from pathsim/feature/bus-connection-rules
Enforce bus wiring rules and highlight connectable ports
2 parents 8d3f381 + bf54a90 commit ceefc82

8 files changed

Lines changed: 172 additions & 5 deletions

File tree

‎docs/pvm-spec.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,8 @@ In PathSim Python code, subsystems map to `Subsystem(blocks=[...], connections=[
320320
- A subsystem port carrying a bus becomes one port index per leaf signal, in bus order, on the Subsystem and on its Interface. Port indices after it shift accordingly.
321321
- Wiring that cannot be resolved is left out: a bus into a block that is not a bus block or subsystem, a picked signal missing from the bus, or a wire loop through bus blocks.
322322

323+
Editors should not create such wiring: a bus may only enter a Bus Creator, a Bus Selector or a subsystem port, and a Bus Selector only takes a bus. PathView rejects these wires while connecting and draws existing ones as errors.
324+
323325
The reference implementations are `src/lib/bus/expand.ts` and `pathview/buses.py`; `tests/fixtures/bus_expansion.json` lists the expected wiring for each case.
324326

325327
---

‎src/lib/bus/expand.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22
import type { Connection, NodeInstance } from '$lib/types/nodes';
33
import fixtures from '../../../tests/fixtures/bus_expansion.json';
4-
import { analyzeBuses, expandBuses, isBusBlock, signalLeaves } from './expand';
4+
import { analyzeBuses, busWiringProblem, expandBuses, isBusBlock, signalLeaves } from './expand';
55

66
type Scenario = (typeof fixtures.scenarios)[number];
77

@@ -49,6 +49,26 @@ describe('bus expansion', () => {
4949
expect(expanded.connections).toBe(connections);
5050
});
5151

52+
it('keeps buses out of plain blocks and plain signals out of selectors', () => {
53+
const { nodes, connections } = load(fixtures.scenarios.find((s) => s.name === 'flat creator and selector')!);
54+
const analysis = analyzeBuses(nodes, connections);
55+
const root = analysis.root;
56+
expect(busWiringProblem(analysis, root, 'C', 0, 'Scope')).toBe('bus-into-block');
57+
expect(busWiringProblem(analysis, root, 'A', 0, 'S')).toBe('signal-into-selector');
58+
expect(busWiringProblem(analysis, root, 'C', 0, 'S')).toBeNull();
59+
expect(busWiringProblem(analysis, root, 'A', 0, 'C')).toBeNull();
60+
expect(busWiringProblem(analysis, root, 'S', 0, 'Scope')).toBeNull();
61+
});
62+
63+
it('applies the bus rules across subsystem ports', () => {
64+
const { nodes, connections } = load(fixtures.scenarios.find((s) => s.name === 'bus into a subsystem')!);
65+
const analysis = analyzeBuses(nodes, connections);
66+
const inner = analysis.levelAt(['Sub'])!;
67+
expect(busWiringProblem(analysis, analysis.root, 'C', 0, 'Sub')).toBeNull();
68+
expect(busWiringProblem(analysis, inner, 'I', 0, 'G')).toBe('bus-into-block');
69+
expect(busWiringProblem(analysis, inner, 'Sel', 0, 'G')).toBeNull();
70+
});
71+
5272
it('follows a bus structure into a subsystem', () => {
5373
const { nodes, connections } = load(fixtures.scenarios.find((s) => s.name === 'bus into a subsystem')!);
5474
const analysis = analyzeBuses(nodes, connections);

‎src/lib/bus/expand.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,38 @@ export function analyzeBuses(nodes: NodeInstance[], connections: Connection[]) {
209209
return { root, levelAt, structureIn, structureOut, elementNames };
210210
}
211211

212+
export type BusAnalysis = ReturnType<typeof analyzeBuses>;
213+
214+
/** Why a wire breaks the bus rules */
215+
export type BusWiringProblem = 'bus-into-block' | 'signal-into-selector';
216+
217+
/**
218+
* A bus may only enter a Bus Creator, a Bus Selector or a subsystem port, and a
219+
* Bus Selector only takes a bus. Returns the rule a wire from the source port to
220+
* the target node breaks, or null if it keeps them.
221+
*/
222+
export function busWiringProblem(
223+
analysis: BusAnalysis,
224+
level: BusLevel,
225+
sourceNodeId: string,
226+
sourcePort: number,
227+
targetNodeId: string
228+
): BusWiringProblem | null {
229+
const target = level.nodes.get(targetNodeId);
230+
if (!target) return null;
231+
const carriesBus = analysis.structureOut(level, sourceNodeId, sourcePort) !== null;
232+
switch (target.type) {
233+
case NODE_TYPES.BUS_CREATOR:
234+
case NODE_TYPES.SUBSYSTEM:
235+
case NODE_TYPES.INTERFACE:
236+
return null;
237+
case NODE_TYPES.BUS_SELECTOR:
238+
return carriesBus ? null : 'signal-into-selector';
239+
default:
240+
return carriesBus ? 'bus-into-block' : null;
241+
}
242+
}
243+
212244
/**
213245
* The model without bus blocks, for code generation. Models without bus blocks
214246
* are returned unchanged. Connections that carry several signals are split,

‎src/lib/components/FlowCanvas.svelte‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@
3939
import { GRID_SIZE, SNAP_GRID, BACKGROUND_GAP } from '$lib/constants/grid';
4040
import { createRoutingSync } from './canvas/routingSync';
4141
import { isBusBlock } from '$lib/bus/expand';
42-
import { updateBusView } from '$lib/stores/busView.svelte';
42+
import { busWireAllowed, updateBusView } from '$lib/stores/busView.svelte';
43+
import { endConnectionDrag, startConnectionDrag } from '$lib/stores/connectionDrag.svelte';
44+
import { HANDLE_ID } from '$lib/constants/handles';
4345
import BusBlockNode from './nodes/BusBlockNode.svelte';
4446
import { createEdgeHighlighter } from '$lib/stores/edgeHighlight';
4547
import { CANVAS_MIN_ZOOM } from '$lib/constants/layout';
@@ -778,6 +780,25 @@
778780
isSyncing = false;
779781
}
780782
783+
// Highlight the ports that can take a wire while it is dragged from a port
784+
function handleConnectStart(
785+
_event: MouseEvent | TouchEvent,
786+
params: { nodeId: string | null; handleId: string | null; handleType: 'source' | 'target' | null }
787+
) {
788+
if (!params.nodeId || !params.handleId || !params.handleType) return;
789+
const isOutput = params.handleType === 'source';
790+
const port = HANDLE_ID.parseIndex(params.handleId, isOutput ? 'output' : 'input');
791+
if (port === null) return;
792+
const occupied = new Set(get(graphStore.connections).map((c) => `${c.targetNodeId}:${c.targetPortIndex}`));
793+
startConnectionDrag({ nodeId: params.nodeId, port, isOutput }, occupied);
794+
}
795+
796+
// A bus may only enter bus blocks and subsystem ports, and a Bus Selector only takes a bus
797+
function isValidConnection(connection: FlowConnection | Edge): boolean {
798+
const sourcePort = HANDLE_ID.parseIndex(connection.sourceHandle ?? '', 'output');
799+
return sourcePort === null || busWireAllowed(connection.source, sourcePort, connection.target);
800+
}
801+
781802
// Handle new connections
782803
function handleConnect(connection: FlowConnection) {
783804
if (!connection.source || !connection.target) return;
@@ -973,6 +994,11 @@
973994
{nodeTypes}
974995
{edgeTypes}
975996
onconnect={readonly ? undefined : handleConnect}
997+
{isValidConnection}
998+
onconnectstart={readonly ? undefined : handleConnectStart}
999+
onconnectend={readonly ? undefined : endConnectionDrag}
1000+
onclickconnectstart={readonly ? undefined : handleConnectStart}
1001+
onclickconnectend={readonly ? undefined : endConnectionDrag}
9761002
onnodedragstart={readonly ? undefined : handleNodeDragStart}
9771003
onnodedrag={readonly ? undefined : handleNodeDrag}
9781004
onnodedragstop={readonly ? undefined : handleNodeDragStop}

‎src/lib/components/edges/OrthogonalEdge.svelte‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
import { GRID_SIZE, EDGE_SOURCE_OFFSET, EDGE_TARGET_OFFSET, EDGE_CORNER_RADIUS } from '$lib/routing/constants';
3737
import InlineInput from '$lib/components/InlineInput.svelte';
3838
import { BUS } from '$lib/constants/dimensions';
39-
import { busWires, busCreatorWires } from '$lib/stores/busView.svelte';
39+
import { busWires, busCreatorWires, invalidBusWires } from '$lib/stores/busView.svelte';
4040
import type { Direction, RouteResult } from '$lib/routing';
4141
import type { Waypoint } from '$lib/types/nodes';
4242
@@ -201,6 +201,9 @@
201201
// Wires carrying a bus are drawn thicker
202202
const carriesBus = $derived(busWires.has(id));
203203
204+
// Wires breaking the bus rules are drawn as errors
205+
const breaksBusRules = $derived(invalidBusWires.has(id));
206+
204207
// A bus wire starts inside the solid bus port, so the thick line joins it without a gap
205208
const adjustedSource = $derived(
206209
alongFacing(sourceX, sourceY, sourcePosition, -(carriesBus ? BUS.sourceInset : EDGE_SOURCE_OFFSET))
@@ -403,6 +406,7 @@
403406
<g
404407
class:highlighted={highlightColor !== undefined}
405408
class:bus-wire={carriesBus}
409+
class:invalid-bus={breaksBusRules}
406410
style="{carriesBus ? `--wire-scale: ${BUS.wireScale};` : ''}{highlightColor !== undefined ? ` --highlight-color: ${highlightColor};` : ''}"
407411
ondblclick={handleEdgeDoubleClick}
408412
>
@@ -489,6 +493,16 @@
489493
fill: var(--accent);
490494
}
491495
496+
/* A wire breaking the bus rules: dashed in the error color, in every state */
497+
.invalid-bus :global(.svelte-flow__edge-path) {
498+
stroke: var(--error) !important;
499+
stroke-dasharray: 4 3;
500+
}
501+
502+
.invalid-bus .edge-arrow {
503+
fill: var(--error) !important;
504+
}
505+
492506
/* Highlight the edge path when handle is hovered */
493507
.highlighted :global(.svelte-flow__edge-path) {
494508
stroke: var(--highlight-color, var(--accent)) !important;

‎src/lib/components/nodes/NodePorts.svelte‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { graphStore } from '$lib/stores/graph';
66
import { historyStore } from '$lib/stores/history';
77
import { hoveredHandle } from '$lib/stores/hoveredHandle';
8+
import { canTakeDraggedWire } from '$lib/stores/connectionDrag.svelte';
89
import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte';
910
import { getPortPositionCalc } from '$lib/constants/dimensions';
1011
import { truncatePortLabel } from '$lib/utils/portLabels';
@@ -80,7 +81,8 @@
8081
8182
const handleClass = (direction: 'input' | 'output', index: number) => {
8283
const bus = (direction === 'input' ? busInputs : busOutputs)?.includes(index);
83-
return `handle handle-${direction}${bus ? ' handle-bus' : ''}`;
84+
const connectable = canTakeDraggedWire(id, index, direction === 'output');
85+
return `handle handle-${direction}${bus ? ' handle-bus' : ''}${connectable ? ' handle-connectable' : ''}`;
8486
};
8587
8688
// Calculate actual port positions based on rotation
@@ -397,6 +399,11 @@
397399
cursor: not-allowed;
398400
}
399401
402+
/* While a wire is dragged, ports that can take it show their outline in the block color, still hollow */
403+
:global(.node .svelte-flow__handle.handle-connectable::before) {
404+
background: var(--node-color, var(--accent));
405+
}
406+
400407
/* Ports carrying a bus: the same arrow as other ports with a heavier outline,
401408
* matching the thicker bus wire. Like other ports it fills on hover and selection. */
402409
:global(.node .svelte-flow__handle.handle-bus::after) {

‎src/lib/stores/busView.svelte.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,29 @@
99
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
1010
import type { Connection, NodeInstance } from '$lib/nodes/types';
1111
import { NODE_TYPES } from '$lib/constants/nodeTypes';
12-
import { analyzeBuses, containsBusBlocks, signalPaths } from '$lib/bus/expand';
12+
import {
13+
analyzeBuses,
14+
busWiringProblem,
15+
containsBusBlocks,
16+
signalPaths,
17+
type BusAnalysis,
18+
type BusLevel
19+
} from '$lib/bus/expand';
1320

1421
/** Wires carrying a bus */
1522
export const busWires = new SvelteSet<string>();
1623

24+
/** Wires breaking the bus rules: a bus into a plain block, or a plain signal into a Bus Selector */
25+
export const invalidBusWires = new SvelteSet<string>();
26+
27+
/** Analysis of the model at the last update, reused to judge wires while connecting */
28+
let current: { analysis: BusAnalysis; level: BusLevel } | null = null;
29+
30+
/** Whether a new wire from the source port to the target node keeps the bus rules */
31+
export function busWireAllowed(sourceNodeId: string, sourcePort: number, targetNodeId: string): boolean {
32+
return !current || busWiringProblem(current.analysis, current.level, sourceNodeId, sourcePort, targetNodeId) === null;
33+
}
34+
1735
/** Bus Creator ID to the signal name of each of its inputs */
1836
export const busCreatorSignals = new SvelteMap<string, string[]>();
1937

@@ -60,12 +78,18 @@ export function updateBusView(
6078
const ports = new Map<string, { inputs: number[]; outputs: number[] }>();
6179
const selectorOptions = new Map<string, string[]>();
6280
const creatorWires = new Set<string>();
81+
const invalidWires = new Set<string>();
82+
current = null;
6383

6484
if (containsBusBlocks(model.nodes)) {
6585
const analysis = analyzeBuses(model.nodes, model.connections);
6686
const level = analysis.levelAt(path);
6787
if (level) {
88+
current = { analysis, level };
6889
for (const connection of connections) {
90+
if (busWiringProblem(analysis, level, connection.sourceNodeId, connection.sourcePortIndex, connection.targetNodeId)) {
91+
invalidWires.add(connection.id);
92+
}
6993
if (analysis.structureOut(level, connection.sourceNodeId, connection.sourcePortIndex)) wires.add(connection.id);
7094
if (level.nodes.get(connection.targetNodeId)?.type === NODE_TYPES.BUS_CREATOR) creatorWires.add(connection.id);
7195
}
@@ -82,6 +106,7 @@ export function updateBusView(
82106
}
83107

84108
syncSet(busWires, wires);
109+
syncSet(invalidBusWires, invalidWires);
85110
sync(busCreatorSignals, creators, sameNames);
86111
sync(busPorts, ports, (a, b) => sameIndices(a.inputs, b.inputs) && sameIndices(a.outputs, b.outputs));
87112
sync(busSelectorOptions, selectorOptions, sameNames);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Connection drag - the port a wire is being dragged from, so ports that can
3+
* take the wire are highlighted while connecting
4+
*/
5+
6+
import { busWireAllowed } from './busView.svelte';
7+
8+
interface DragSource {
9+
nodeId: string;
10+
port: number;
11+
isOutput: boolean;
12+
}
13+
14+
export const connectionDrag = $state<{ from: DragSource | null; occupiedInputs: Set<string> }>({
15+
from: null,
16+
occupiedInputs: new Set()
17+
});
18+
19+
/**
20+
* Start highlighting for a wire dragged from a port
21+
* @param occupiedInputs - Inputs that already receive a wire, as "nodeId:port"
22+
*/
23+
export function startConnectionDrag(from: DragSource, occupiedInputs: Set<string>): void {
24+
connectionDrag.occupiedInputs = occupiedInputs;
25+
connectionDrag.from = from;
26+
}
27+
28+
export function endConnectionDrag(): void {
29+
connectionDrag.from = null;
30+
}
31+
32+
/**
33+
* Whether a port can take the wire being dragged: a free input for a wire from
34+
* an output, any output for a wire from an input, both within the bus rules
35+
*/
36+
export function canTakeDraggedWire(nodeId: string, port: number, isOutput: boolean): boolean {
37+
const from = connectionDrag.from;
38+
if (!from || from.isOutput === isOutput) return false;
39+
if (isOutput) return busWireAllowed(nodeId, port, from.nodeId);
40+
return !connectionDrag.occupiedInputs.has(`${nodeId}:${port}`) && busWireAllowed(from.nodeId, from.port, nodeId);
41+
}

0 commit comments

Comments
 (0)