Skip to content

Commit 0ae41bb

Browse files
authored
Merge pull request #239 from pathsim/feature/sync-mimo-ports
sync mimo ports
2 parents 58d3713 + e85720e commit 0ae41bb

File tree

7 files changed

+96
-5
lines changed

7 files changed

+96
-5
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ src/
5959
│ │ ├── core/ # Constants, types, utilities
6060
│ │ ├── processing/ # Data processing, render queue
6161
│ │ └── renderers/ # Plotly and SVG renderers
62+
│ ├── routing/ # Orthogonal wire routing (A* pathfinding)
6263
│ ├── pyodide/ # Python runtime (backend, bridge)
6364
│ │ └── backend/ # Modular backend system (registry, state, types)
6465
│ │ └── pyodide/ # Pyodide Web Worker implementation
@@ -120,6 +121,19 @@ Worker (10 Hz) Main Thread UI (10 Hz)
120121
- **Non-blocking**: Simulation never waits for plot rendering
121122
- **extendTraces**: Scope plots append data incrementally instead of full re-render
122123

124+
### Wire Routing
125+
126+
PathView uses Simulink-style orthogonal wire routing with A* pathfinding:
127+
128+
- **Automatic routing**: Wires route around nodes with 90° bends only
129+
- **User waypoints**: Press `\` on selected edge to add manual waypoints
130+
- **Draggable waypoints**: Drag waypoint markers to reposition, double-click to delete
131+
- **Segment dragging**: Drag segment midpoints to create new waypoints
132+
- **Incremental updates**: Spatial indexing (O(1) node updates) for smooth dragging
133+
- **Hybrid routing**: Routes through user waypoints: Source → A* → W1 → A* → Target
134+
135+
Key files: `src/lib/routing/` (pathfinder, grid builder, route calculator)
136+
123137
### Key Abstractions
124138

125139
| Layer | Purpose | Key Files |
@@ -203,6 +217,27 @@ This generates TypeScript files in `src/lib/*/generated/` with:
203217

204218
Start the dev server and check that your block appears in the Block Library panel.
205219

220+
### Port Synchronization
221+
222+
Some blocks process inputs as parallel paths where each input has a corresponding output (e.g., Integrator, Amplifier, Sin). For these blocks, the UI only shows input port controls and outputs auto-sync.
223+
224+
Configure in `src/lib/nodes/uiConfig.ts`:
225+
226+
```typescript
227+
export const syncPortBlocks = new Set([
228+
'Integrator',
229+
'Differentiator',
230+
'Delay',
231+
'PID',
232+
'PID_Antiwindup',
233+
'Amplifier',
234+
'Sin', 'Cos', 'Tan', 'Tanh',
235+
'Abs', 'Sqrt', 'Exp', 'Log', 'Log10',
236+
'Mod', 'Clip', 'Pow',
237+
'SampleHold'
238+
]);
239+
```
240+
206241
---
207242

208243
## Adding New Toolboxes
@@ -448,6 +483,7 @@ Press `?` to see all shortcuts in the app. Key shortcuts:
448483
| **Transform** | `R` | Rotate 90° |
449484
| | `X` / `Y` | Flip H/V |
450485
| | `Arrows` | Nudge selection |
486+
| **Wires** | `\` | Add waypoint to selected edge |
451487
| **View** | `F` | Fit view |
452488
| | `H` | Go to root |
453489
| | `T` | Toggle theme |

src/lib/components/nodes/BaseNode.svelte

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
// Check if this node allows dynamic ports
9090
const allowsDynamicInputs = $derived(typeDef?.ports.maxInputs === null);
9191
const allowsDynamicOutputs = $derived(typeDef?.ports.maxOutputs === null);
92+
const syncPorts = $derived(typeDef?.ports.syncPorts ?? false);
9293
9394
// Rotation state (0, 1, 2, 3 = 0°, 90°, 180°, 270°) - stored in node params
9495
const rotation = $derived((data.params?.['_rotation'] as number) || 0);
@@ -348,8 +349,8 @@
348349
</div>
349350
{/if}
350351

351-
<!-- Port controls for dynamic outputs (only show when selected) -->
352-
{#if allowsDynamicOutputs && selected}
352+
<!-- Port controls for dynamic outputs (only show when selected, hide for syncPorts blocks) -->
353+
{#if allowsDynamicOutputs && selected && !syncPorts}
353354
<div class="port-controls port-controls-output" class:port-controls-right={rotation === 0} class:port-controls-bottom={rotation === 1} class:port-controls-left={rotation === 2} class:port-controls-top={rotation === 3}>
354355
<button class="port-btn" onclick={handleAddOutput} ondblclick={(e) => e.stopPropagation()} title="Add output">+</button>
355356
<button class="port-btn" onclick={handleRemoveOutput} ondblclick={(e) => e.stopPropagation()} disabled={data.outputs.length <= minOutputs} title="Remove output">-</button>

src/lib/nodes/defineNode.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface DefineNodeOptions {
1919
minOutputs?: number;
2020
maxInputs?: number | null; // null = unlimited
2121
maxOutputs?: number | null;
22+
syncPorts?: boolean; // When true, output count always equals input count
2223

2324
// Shape override (defaults based on category)
2425
shape?: NodeShape;
@@ -52,6 +53,7 @@ export function defineNode(options: DefineNodeOptions): NodeTypeDefinition {
5253
minOutputs = 1,
5354
maxInputs = null,
5455
maxOutputs = null,
56+
syncPorts,
5557
shape,
5658
params = {}
5759
} = options;
@@ -89,7 +91,8 @@ export function defineNode(options: DefineNodeOptions): NodeTypeDefinition {
8991
minInputs,
9092
minOutputs,
9193
maxInputs,
92-
maxOutputs
94+
maxOutputs,
95+
syncPorts
9396
},
9497

9598
params: paramDefs

src/lib/nodes/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import type { NodeTypeDefinition, NodeCategory, ParamDefinition, ParamType } from './types';
77
import { defineNode } from './defineNode';
88
import { extractedBlocks, blockConfig, type ExtractedBlock } from './generated/blocks';
9+
import { syncPortBlocks } from './uiConfig';
910

1011
class NodeRegistry {
1112
private nodes: Map<string, NodeTypeDefinition> = new Map();
@@ -150,6 +151,7 @@ function createNodeFromExtracted(
150151
outputs,
151152
maxInputs,
152153
maxOutputs,
154+
syncPorts: syncPortBlocks.has(name),
153155
params
154156
});
155157

src/lib/nodes/uiConfig.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* UI-specific block configuration
3+
* Separate from auto-generated blocks.ts to allow UI behavior overrides
4+
*/
5+
6+
/**
7+
* Blocks where output port count must equal input port count.
8+
* These blocks process inputs as parallel paths - each input has a corresponding output.
9+
* UI shows only input port controls; outputs auto-sync.
10+
*/
11+
export const syncPortBlocks = new Set([
12+
// Dynamic blocks (parallel integration/differentiation/delay)
13+
'Integrator',
14+
'Differentiator',
15+
'Delay',
16+
'PID',
17+
'PID_Antiwindup',
18+
19+
// Algebraic blocks (element-wise operations)
20+
'Amplifier',
21+
'Sin',
22+
'Cos',
23+
'Tan',
24+
'Tanh',
25+
'Abs',
26+
'Sqrt',
27+
'Exp',
28+
'Log',
29+
'Log10',
30+
'Mod',
31+
'Clip',
32+
'Pow',
33+
34+
// Mixed blocks (parallel sampling)
35+
'SampleHold'
36+
]);

src/lib/stores/graph/ports.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ function updateParentSubsystem(
6666

6767
/**
6868
* Internal: Add a port to a node
69+
* @param syncFollowUp - If true, this is a follow-up call from syncPorts logic (prevents recursion)
6970
*/
70-
function addPort(nodeId: string, direction: PortDirection): boolean {
71+
function addPort(nodeId: string, direction: PortDirection, syncFollowUp = false): boolean {
7172
const currentGraph = getCurrentGraph();
7273
const node = currentGraph.nodes.get(nodeId);
7374
if (!node) return false;
@@ -128,13 +129,19 @@ function addPort(nodeId: string, direction: PortDirection): boolean {
128129
[config.portsKey]: [...(n[config.portsKey] as PortInstance[]), newPort]
129130
}));
130131

132+
// Sync outputs to match inputs for parallel-path blocks
133+
if (!syncFollowUp && direction === 'input' && typeDef?.ports.syncPorts) {
134+
addPort(nodeId, 'output', true);
135+
}
136+
131137
return true;
132138
}
133139

134140
/**
135141
* Internal: Remove the last port from a node
142+
* @param syncFollowUp - If true, this is a follow-up call from syncPorts logic (prevents recursion)
136143
*/
137-
function removePort(nodeId: string, direction: PortDirection): boolean {
144+
function removePort(nodeId: string, direction: PortDirection, syncFollowUp = false): boolean {
138145
const currentGraph = getCurrentGraph();
139146
const node = currentGraph.nodes.get(nodeId);
140147
if (!node) return false;
@@ -194,6 +201,11 @@ function removePort(nodeId: string, direction: PortDirection): boolean {
194201
})
195202
);
196203

204+
// Sync outputs to match inputs for parallel-path blocks
205+
if (!syncFollowUp && direction === 'input' && typeDef?.ports.syncPorts) {
206+
removePort(nodeId, 'output', true);
207+
}
208+
197209
return true;
198210
}
199211

src/lib/types/nodes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface NodeTypeDefinition {
6767
minOutputs: number; // minimum number of output ports (default 1)
6868
maxInputs: number | null; // null = unlimited
6969
maxOutputs: number | null;
70+
syncPorts?: boolean; // When true, output count always equals input count (parallel paths)
7071
};
7172

7273
// Parameter definitions

0 commit comments

Comments
 (0)