From 591e7835dc2b235396be94708d3dfe8034f9b815 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 13 Sep 2026 23:03:02 +0200 Subject: [PATCH 01/17] Extract inline input with optional suggestions from label editor --- src/lib/components/InlineInput.svelte | 229 ++++++++++++++++++ .../components/edges/OrthogonalEdge.svelte | 69 +----- src/lib/constants/dimensions.ts | 12 +- 3 files changed, 239 insertions(+), 71 deletions(-) create mode 100644 src/lib/components/InlineInput.svelte diff --git a/src/lib/components/InlineInput.svelte b/src/lib/components/InlineInput.svelte new file mode 100644 index 00000000..eb34c30b --- /dev/null +++ b/src/lib/components/InlineInput.svelte @@ -0,0 +1,229 @@ + + + + +{#if options.length > 0 && listPosition} +
+ {#each options as option, i (option.create ? '' : option.text)} +
pick(event, option)} + onpointerenter={() => (activeIndex = i)} + > + {#if option.create && createLabel} + {createLabel(option.text)} + {:else if query} + {option.text.slice(0, option.match)}{option.text.slice(option.match, option.match + query.length)}{option.text.slice(option.match + query.length)} + {:else} + {option.text} + {/if} +
+ {/each} +
+{/if} + + diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index c5c5c485..0c6a5e13 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -34,7 +34,7 @@ import { historyStore } from '$lib/stores/history'; import { screenToFlow } from '$lib/utils/viewUtils'; import { GRID_SIZE, EDGE_SOURCE_OFFSET, EDGE_TARGET_OFFSET, EDGE_CORNER_RADIUS } from '$lib/routing/constants'; - import { EDGE_LABEL } from '$lib/constants/dimensions'; + import InlineInput from '$lib/components/InlineInput.svelte'; import type { Direction, RouteResult } from '$lib/routing'; import type { Waypoint } from '$lib/types/nodes'; @@ -302,9 +302,6 @@ const label = $derived((data as { label?: string } | undefined)?.label ?? ''); const isEditingLabel = $derived(edgeLabelEdit.connectionId === id); - // Editor width follows the typed text - let draftLength = $state(0); - const labelAnchor = $derived.by(() => { if (!label && !isEditingLabel) return null; const points = displayedRoute @@ -334,42 +331,6 @@ historyStore.mutate(() => graphStore.updateConnectionLabel(id, text)); } - /** - * Label editor input. Enter commits, Escape cancels, a pointer press outside - * the input commits. Blur alone never commits, because removing the editor - * also blurs the input. Keys are stopped at the input so edge keyboard - * handling and app shortcuts never see them. Focus waits until the input - * sits in the label layer. - */ - function labelEditor(input: HTMLInputElement) { - const onKeydown = (event: KeyboardEvent) => { - event.stopPropagation(); - if (event.key === 'Enter') commitLabel(input.value); - else if (event.key === 'Escape') editEdgeLabel(null); - }; - const onPointerDown = (event: PointerEvent) => { - if (event.target !== input) commitLabel(input.value); - }; - const onInput = () => { - draftLength = input.value.length; - }; - draftLength = input.value.length; - input.addEventListener('keydown', onKeydown); - input.addEventListener('input', onInput); - document.addEventListener('pointerdown', onPointerDown, true); - requestAnimationFrame(() => { - input.focus(); - input.select(); - }); - return { - destroy: () => { - input.removeEventListener('keydown', onKeydown); - input.removeEventListener('input', onInput); - document.removeEventListener('pointerdown', onPointerDown, true); - } - }; - } - // Segment drag creates a waypoint, then drags it function handleSegmentPointerDown(event: PointerEvent, segmentIndex: number) { event.stopPropagation(); @@ -477,13 +438,7 @@ {#if isEditingLabel && labelAnchor} - + editEdgeLabel(null)} /> {/if} @@ -539,26 +494,6 @@ fill: var(--highlight-color, var(--accent)); } - /* Inline label editor: same capsule, active like a dragged waypoint */ - .edge-label-input { - box-sizing: border-box; - height: var(--label-height); - width: calc(var(--label-chars) * 1ch + 2 * var(--label-padding-x)); - padding: 0 var(--label-padding-x); - border: 1.5px solid var(--accent); - border-radius: calc(var(--label-height) / 2); - background: var(--surface); - color: var(--text); - font-family: var(--font-ui); - font-size: var(--font-xs); - text-align: center; - outline: none; - } - - .edge-label-input::placeholder { - color: var(--text-muted); - } - /* Waypoint group - visibility controlled by inline styles */ .waypoint-group { transition: opacity 0.1s ease; diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index 8f5f3958..63b4bce6 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -36,14 +36,18 @@ export const HANDLE = { hollowInset: 1.5 } as const; -/** Connection label capsule, shared by the drawn label and its inline editor */ -export const EDGE_LABEL = { +/** Inline text input on the canvas (connection labels, bus signal names) */ +export const INLINE_INPUT = { /** Capsule height in pixels */ height: 14, /** Space between text and capsule ends in pixels */ paddingX: 6, - /** Minimum editor width in characters */ - minChars: 5 + /** Minimum capsule width in characters */ + minChars: 5, + /** Screen distance between capsule and suggestion list in pixels */ + listGap: 4, + /** Most suggestions shown at once */ + maxSuggestions: 8 } as const; /** Event node dimensions (grid-aligned) */ From 2f6562abd851de5684992d7aa0442ea098c090a4 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 17:28:41 +0200 Subject: [PATCH 02/17] Resolve bus creator and selector blocks before code generation in editor and converter --- pathview/buses.py | 345 ++++++++++++++++++++++++++++++ pathview/converter.py | 6 +- src/lib/bus/expand.test.ts | 59 +++++ src/lib/bus/expand.ts | 322 ++++++++++++++++++++++++++++ src/lib/constants/nodeTypes.ts | 7 +- tests/fixtures/bus_expansion.json | 221 +++++++++++++++++++ tests/test_bus_expansion.py | 66 ++++++ 7 files changed, 1022 insertions(+), 4 deletions(-) create mode 100644 pathview/buses.py create mode 100644 src/lib/bus/expand.test.ts create mode 100644 src/lib/bus/expand.ts create mode 100644 tests/fixtures/bus_expansion.json create mode 100644 tests/test_bus_expansion.py diff --git a/pathview/buses.py b/pathview/buses.py new file mode 100644 index 00000000..904f3460 --- /dev/null +++ b/pathview/buses.py @@ -0,0 +1,345 @@ +"""Buses: virtual Bus Creator and Bus Selector blocks. + +A Bus Creator bundles its input signals into one bus signal, a Bus Selector +picks signals out of a bus by name. Both exist only in the editor. Before code +generation the model is rewritten without them: every picked signal is wired +directly from its source, and a subsystem port carrying a bus becomes one port +index per signal in the bus. Subsystem and Interface have no fixed port count +in pathsim, so only the port indices of connections change. + +Mirrors src/lib/bus/expand.ts; tests/fixtures/bus_expansion.json keeps both +implementations in agreement. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +BUS_CREATOR = "BusCreator" +BUS_SELECTOR = "BusSelector" +SUBSYSTEM = "Subsystem" +INTERFACE = "Interface" +SEPARATOR = "." + +# A signal structure is None for a plain signal, else a list of (name, structure) elements + + +def is_bus_block(node: dict) -> bool: + return node.get("type") in (BUS_CREATOR, BUS_SELECTOR) + + +def selected_signals(node: dict) -> list[str]: + """Signal paths a Bus Selector picks, one per output.""" + value = (node.get("params") or {}).get("signals") + return [str(v) for v in value] if isinstance(value, list) else [] + + +def signal_leaves(structure) -> list[str]: + """Leaf signal paths of a structure; a plain signal has the single empty path.""" + if structure is None: + return [""] + leaves = [] + for name, sub in structure: + for path in signal_leaves(sub): + leaves.append(f"{name}{SEPARATOR}{path}" if path else name) + return leaves + + +def element_at(structure, path: str): + """Structure of the element at a dotted signal path, and whether it exists.""" + current = structure + found = False + for name in path.split(SEPARATOR): + match = next((sub for element, sub in (current or []) if element == name), _MISSING) + if match is _MISSING: + return False, None + current = match + found = True + return found, current + + +_MISSING = object() + + +def _contains_bus_blocks(nodes: list[dict]) -> bool: + return any( + is_bus_block(n) or (bool(n.get("graph")) and _contains_bus_blocks(n["graph"].get("nodes", []))) + for n in nodes + ) + + +@dataclass(eq=False) +class BusLevel: + """One graph level: the root graph or the graph inside a subsystem.""" + + id: int + node_list: list[dict] + nodes: dict[str, dict] + connections: list[dict] + incoming: dict[str, dict] + parent: tuple[BusLevel, dict] | None + children: dict[str, BusLevel] = field(default_factory=dict) + + +class BusModel: + """Signal structures of a whole model, following wires through bus blocks and subsystems.""" + + def __init__(self, nodes: list[dict], connections: list[dict]): + self._next_id = 0 + self._memo: dict[str, object] = {} + self._visiting: set[str] = set() + self.root = self._build_level(nodes, connections, None) + + def _build_level(self, node_list, connections, parent) -> BusLevel: + level = BusLevel( + id=self._next_id, + node_list=node_list, + nodes={n["id"]: n for n in node_list}, + connections=connections, + incoming={f"{c['targetNodeId']}:{c['targetPortIndex']}": c for c in connections}, + parent=parent, + ) + self._next_id += 1 + for node in node_list: + graph = node.get("graph") + if node.get("type") == SUBSYSTEM and graph: + level.children[node["id"]] = self._build_level( + graph.get("nodes", []), graph.get("connections", []), (level, node) + ) + return level + + def level_at(self, path: list[str]) -> BusLevel | None: + level = self.root + for node_id in path: + level = level.children.get(node_id) + if level is None: + return None + return level + + def _source_port_name(self, level: BusLevel, connection: dict) -> str | None: + source = level.nodes.get(connection["sourceNodeId"]) + port = connection["sourcePortIndex"] + if source is not None and source.get("type") == INTERFACE and level.parent: + ports = level.parent[1].get("inputs", []) + else: + ports = (source or {}).get("outputs", []) + return ports[port].get("name") if port < len(ports) else None + + def _element_names(self, level: BusLevel, creator: dict) -> list[str]: + """Wire label, else source port name, else the creator's input name; unique.""" + used: set[str] = set() + names = [] + for i, port in enumerate(creator.get("inputs", [])): + connection = level.incoming.get(f"{creator['id']}:{i}") + label = (connection.get("label") or "").strip() if connection else "" + source_name = self._source_port_name(level, connection) if connection else None + raw = label or source_name or port.get("name") or f"signal {i}" + base = "_".join(raw.split(SEPARATOR)) + name = base + n = 2 + while name in used: + name = f"{base}_{n}" + n += 1 + used.add(name) + names.append(name) + return names + + def structure_in(self, level: BusLevel, node_id: str, port: int): + connection = level.incoming.get(f"{node_id}:{port}") + if connection is None: + return None + return self.structure_out(level, connection["sourceNodeId"], connection["sourcePortIndex"]) + + def structure_out(self, level: BusLevel, node_id: str, port: int): + key = f"{level.id}:{node_id}:{port}" + if key in self._memo: + return self._memo[key] + # A wire loop through bus blocks has no defined structure + if key in self._visiting: + return None + self._visiting.add(key) + structure = self._compute_out(level, node_id, port) + self._visiting.discard(key) + self._memo[key] = structure + return structure + + def _compute_out(self, level: BusLevel, node_id: str, port: int): + node = level.nodes.get(node_id) + if node is None: + return None + kind = node.get("type") + if kind == BUS_CREATOR: + return [ + (name, self.structure_in(level, node_id, i)) + for i, name in enumerate(self._element_names(level, node)) + ] + if kind == BUS_SELECTOR: + paths = selected_signals(node) + if port >= len(paths) or not paths[port]: + return None + return element_at(self.structure_in(level, node_id, 0), paths[port])[1] + if kind == SUBSYSTEM: + inner = level.children.get(node_id) + iface = next((n for n in inner.node_list if n.get("type") == INTERFACE), None) if inner else None + return self.structure_in(inner, iface["id"], port) if iface else None + if kind == INTERFACE and level.parent: + outer, subsystem = level.parent + return self.structure_in(outer, subsystem["id"], port) + return None + + +def expand_buses(nodes: list[dict], connections: list[dict]) -> tuple[list[dict], list[dict]]: + """The model without bus blocks, for code generation. + + Models without bus blocks are returned unchanged. Connections that carry + several signals are split, with IDs suffixed by the signal index. Wiring + that cannot be resolved, such as a bus into a plain block or a signal + missing from a bus, is left out. + """ + if not _contains_bus_blocks(nodes): + return nodes, connections + return _Expansion(BusModel(nodes, connections)).expand() + + +class _Expansion: + def __init__(self, model: BusModel): + self.model = model + self._offsets: dict[str, list[int]] = {} + self._resolving: set[str] = set() + + @staticmethod + def _count(structure) -> int: + return len(signal_leaves(structure)) + + @staticmethod + def _range(node_id: str, offset: int, length: int) -> list[tuple[str, int] | None]: + return [(node_id, offset + j) for j in range(length)] + + def _offsets_of(self, level: BusLevel, subsystem: dict, direction: str) -> list[int]: + key = f"{level.id}:{subsystem['id']}:{direction}" + if key in self._offsets: + return self._offsets[key] + ports = len(subsystem.get("inputs" if direction == "in" else "outputs", [])) + result = [] + total = 0 + for i in range(ports): + result.append(total) + if direction == "in": + total += self._count(self.model.structure_in(level, subsystem["id"], i)) + else: + total += self._count(self.model.structure_out(level, subsystem["id"], i)) + self._offsets[key] = result + return result + + def _offset(self, level: BusLevel, subsystem: dict, direction: str, port: int) -> int: + offsets = self._offsets_of(level, subsystem, direction) + return offsets[port] if port < len(offsets) else port + + def _resolve_in(self, level: BusLevel, node_id: str, port: int): + connection = level.incoming.get(f"{node_id}:{port}") + if connection is None: + return [None] + return self._resolve_out(level, connection["sourceNodeId"], connection["sourcePortIndex"]) + + def _resolve_out(self, level: BusLevel, node_id: str, port: int): + """Real source of every signal leaving an output, in leaf order.""" + key = f"{level.id}:{node_id}:{port}" + if key in self._resolving: + return [None] + self._resolving.add(key) + endpoints = self._compute_resolve_out(level, node_id, port) + self._resolving.discard(key) + return endpoints + + def _compute_resolve_out(self, level: BusLevel, node_id: str, port: int): + model = self.model + node = level.nodes.get(node_id) + if node is None: + return [None] + kind = node.get("type") + if kind == BUS_CREATOR: + return [e for i in range(len(node.get("inputs", []))) for e in self._resolve_in(level, node_id, i)] + if kind == BUS_SELECTOR: + paths = selected_signals(node) + path = paths[port] if port < len(paths) else "" + everything = self._resolve_in(level, node_id, 0) + leaves = signal_leaves(model.structure_in(level, node_id, 0)) + picked = [] + if path: + for i, leaf in enumerate(leaves): + if leaf == path or leaf.startswith(path + SEPARATOR): + picked.append(everything[i] if i < len(everything) else None) + return picked or [None] + if kind == SUBSYSTEM: + return self._range( + node_id, self._offset(level, node, "out", port), self._count(model.structure_out(level, node_id, port)) + ) + if kind == INTERFACE: + if not level.parent: + return [(node_id, port)] + outer, subsystem = level.parent + return self._range( + node_id, + self._offset(outer, subsystem, "in", port), + self._count(model.structure_in(outer, subsystem["id"], port)), + ) + return [(node_id, port)] + + def _input_slots(self, level: BusLevel, node: dict, port: int): + """Expanded input slots a connection into a port lands on; bus blocks take none.""" + model = self.model + kind = node.get("type") + if kind in (BUS_CREATOR, BUS_SELECTOR): + return [] + if kind == SUBSYSTEM: + return self._range( + node["id"], self._offset(level, node, "in", port), self._count(model.structure_in(level, node["id"], port)) + ) + if kind == INTERFACE and level.parent: + outer, subsystem = level.parent + return self._range( + node["id"], + self._offset(outer, subsystem, "out", port), + self._count(model.structure_out(outer, subsystem["id"], port)), + ) + return [(node["id"], port)] + + def expand(self) -> tuple[list[dict], list[dict]]: + return self._expand_level(self.model.root) + + def _expand_level(self, level: BusLevel) -> tuple[list[dict], list[dict]]: + nodes = [] + for node in level.node_list: + if is_bus_block(node): + continue + inner = level.children.get(node["id"]) + if inner is not None and node.get("graph"): + child_nodes, child_connections = self._expand_level(inner) + nodes.append({**node, "graph": {**node["graph"], "nodes": child_nodes, "connections": child_connections}}) + else: + nodes.append(node) + + connections = [] + for connection in level.connections: + target = level.nodes.get(connection["targetNodeId"]) + if target is None or connection["sourceNodeId"] not in level.nodes: + connections.append(connection) + continue + targets = self._input_slots(level, target, connection["targetPortIndex"]) + if not targets: + continue + sources = self._resolve_out(level, connection["sourceNodeId"], connection["sourcePortIndex"]) + if len(sources) != len(targets): + continue + for i, (source, slot) in enumerate(zip(sources, targets)): + if source is None or slot is None: + continue + connections.append({ + **connection, + "id": connection["id"] if len(sources) == 1 else f"{connection['id']}{SEPARATOR}{i}", + "sourceNodeId": source[0], + "sourcePortIndex": source[1], + "targetNodeId": slot[0], + "targetPortIndex": slot[1], + }) + return nodes, connections diff --git a/pathview/converter.py b/pathview/converter.py index 1dd613c7..a8e13edc 100644 --- a/pathview/converter.py +++ b/pathview/converter.py @@ -16,6 +16,8 @@ from pathlib import Path from typing import Any +from pathview.buses import expand_buses + # ============================================================================= # Registry @@ -322,8 +324,8 @@ def generate_python(pvm: dict, registry: dict, source_name: str = "") -> str: divider = "# " + "\u2500" * 76 graph = pvm.get("graph", {}) - nodes = graph.get("nodes", []) - connections = graph.get("connections", []) + # Bus Creator and Bus Selector exist only in the editor; wire their signals directly + nodes, connections = expand_buses(graph.get("nodes", []), graph.get("connections", [])) events = pvm.get("events", []) code_context = pvm.get("codeContext", {}).get("code", "") settings = pvm.get("simulationSettings", {}) diff --git a/src/lib/bus/expand.test.ts b/src/lib/bus/expand.test.ts new file mode 100644 index 00000000..1352d1ff --- /dev/null +++ b/src/lib/bus/expand.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import type { Connection, NodeInstance } from '$lib/types/nodes'; +import fixtures from '../../../tests/fixtures/bus_expansion.json'; +import { analyzeBuses, expandBuses, isBusBlock, signalLeaves } from './expand'; + +type Scenario = (typeof fixtures.scenarios)[number]; + +const wiring = (connections: Connection[]) => + connections.map((c) => `${c.sourceNodeId}:${c.sourcePortIndex}>${c.targetNodeId}:${c.targetPortIndex}`).sort(); + +/** Wiring per level, keyed by subsystem ID path */ +function levels(nodes: NodeInstance[], connections: Connection[], path = ''): Record { + const result: Record = { [path]: wiring(connections) }; + for (const node of nodes) { + if (!node.graph) continue; + Object.assign(result, levels(node.graph.nodes, node.graph.connections, path ? `${path}/${node.id}` : node.id)); + } + return result; +} + +function anyBusBlock(nodes: NodeInstance[]): boolean { + return nodes.some((n) => isBusBlock(n) || (n.graph ? anyBusBlock(n.graph.nodes) : false)); +} + +function load(scenario: Scenario) { + return { + nodes: scenario.nodes as unknown as NodeInstance[], + connections: scenario.connections as unknown as Connection[] + }; +} + +describe('bus expansion', () => { + for (const scenario of fixtures.scenarios) { + it(scenario.name, () => { + const { nodes, connections } = load(scenario); + const expanded = expandBuses(nodes, connections); + const expected = Object.fromEntries( + Object.entries(scenario.expected as Record).map(([key, value]) => [key, [...value].sort()]) + ); + expect(levels(expanded.nodes, expanded.connections)).toEqual(expected); + expect(anyBusBlock(expanded.nodes)).toBe(false); + }); + } + + it('returns a model without bus blocks as it is', () => { + const { nodes, connections } = load(fixtures.scenarios.find((s) => s.name.includes('without buses'))!); + const expanded = expandBuses(nodes, connections); + expect(expanded.nodes).toBe(nodes); + expect(expanded.connections).toBe(connections); + }); + + it('follows a bus structure into a subsystem', () => { + const { nodes, connections } = load(fixtures.scenarios.find((s) => s.name === 'bus into a subsystem')!); + const analysis = analyzeBuses(nodes, connections); + const inner = analysis.levelAt(['Sub'])!; + expect(signalLeaves(analysis.structureOut(inner, 'I', 0))).toEqual(['a', 'b']); + expect(signalLeaves(analysis.structureIn(analysis.root, 'Scope', 0))).toEqual(['']); + }); +}); diff --git a/src/lib/bus/expand.ts b/src/lib/bus/expand.ts new file mode 100644 index 00000000..6a92e1bb --- /dev/null +++ b/src/lib/bus/expand.ts @@ -0,0 +1,322 @@ +/** + * Buses - virtual Bus Creator and Bus Selector blocks + * + * A Bus Creator bundles its input signals into one bus signal, a Bus Selector + * picks signals out of a bus by name. Both exist only in the editor. Before code + * generation the model is rewritten without them: every picked signal is wired + * directly from its source, and a subsystem port carrying a bus becomes one port + * index per signal in the bus. Subsystem and Interface have no fixed port count + * in pathsim, so only the port indices of connections change. + * + * pathview/converter.py implements the same rules; tests/fixtures/bus_expansion.json + * keeps both implementations in agreement. + */ + +import type { Connection, NodeInstance } from '$lib/types/nodes'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; + +/** Signal structure: null for a plain signal, else the named elements of a bus */ +export type BusStructure = BusElement[] | null; + +export interface BusElement { + name: string; + structure: BusStructure; +} + +/** One graph level: the root graph or the graph inside a subsystem */ +export interface BusLevel { + id: number; + nodeList: NodeInstance[]; + nodes: Map; + connections: Connection[]; + /** Connection into each input, keyed by "nodeId:port" */ + incoming: Map; + /** Subsystem owning this level and the level the subsystem sits in */ + parent: { level: BusLevel; subsystem: NodeInstance } | null; + /** Levels inside the subsystems of this level, by subsystem ID */ + children: Map; +} + +type Endpoint = { nodeId: string; port: number } | null; + +const SEPARATOR = '.'; + +export function isBusBlock(node: NodeInstance): boolean { + return node.type === NODE_TYPES.BUS_CREATOR || node.type === NODE_TYPES.BUS_SELECTOR; +} + +/** Signal paths a Bus Selector picks, one per output */ +export function selectedSignals(node: NodeInstance): string[] { + const value = node.params?.signals; + return Array.isArray(value) ? value.map(String) : []; +} + +/** Leaf signal paths of a structure; a plain signal has the single empty path */ +export function signalLeaves(structure: BusStructure): string[] { + if (!structure) return ['']; + return structure.flatMap((element) => + signalLeaves(element.structure).map((path) => (path ? `${element.name}${SEPARATOR}${path}` : element.name)) + ); +} + +/** Element at a dotted signal path */ +export function elementAt(structure: BusStructure, path: string): BusElement | undefined { + let current: BusElement | undefined; + let elements = structure; + for (const name of path.split(SEPARATOR)) { + current = elements?.find((e) => e.name === name); + if (!current) return undefined; + elements = current.structure; + } + return current; +} + +function containsBusBlocks(nodes: NodeInstance[]): boolean { + return nodes.some((n) => isBusBlock(n) || (n.graph ? containsBusBlocks(n.graph.nodes) : false)); +} + +/** + * Signal structures of a whole model. Structures follow wires through Bus + * Creators, Bus Selectors and subsystem boundaries in both directions. + */ +export function analyzeBuses(nodes: NodeInstance[], connections: Connection[]) { + let nextLevelId = 0; + + function buildLevel(nodeList: NodeInstance[], levelConnections: Connection[], parent: BusLevel['parent']): BusLevel { + const level: BusLevel = { + id: nextLevelId++, + nodeList, + nodes: new Map(nodeList.map((n) => [n.id, n])), + connections: levelConnections, + incoming: new Map(levelConnections.map((c) => [`${c.targetNodeId}:${c.targetPortIndex}`, c])), + parent, + children: new Map() + }; + for (const node of nodeList) { + if (node.type === NODE_TYPES.SUBSYSTEM && node.graph) { + level.children.set(node.id, buildLevel(node.graph.nodes, node.graph.connections, { level, subsystem: node })); + } + } + return level; + } + + const root = buildLevel(nodes, connections, null); + const memo = new Map(); + const visiting = new Set(); + + const interfaceOf = (level: BusLevel) => level.nodeList.find((n) => n.type === NODE_TYPES.INTERFACE); + + /** Name of the port a connection comes from; Interface outputs take the owning subsystem's input names */ + function sourcePortName(level: BusLevel, connection: Connection): string | undefined { + const source = level.nodes.get(connection.sourceNodeId); + if (source?.type === NODE_TYPES.INTERFACE && level.parent) { + return level.parent.subsystem.inputs[connection.sourcePortIndex]?.name; + } + return source?.outputs[connection.sourcePortIndex]?.name; + } + + /** Element names of a Bus Creator: wire label, else source port name, else its own input name; unique */ + function elementNames(level: BusLevel, creator: NodeInstance): string[] { + const used = new Set(); + return creator.inputs.map((input, i) => { + const connection = level.incoming.get(`${creator.id}:${i}`); + const raw = + connection?.label?.trim() || (connection && sourcePortName(level, connection)) || input.name || `signal ${i}`; + const base = raw.split(SEPARATOR).join('_'); + let name = base; + for (let n = 2; used.has(name); n++) name = `${base}_${n}`; + used.add(name); + return name; + }); + } + + function structureIn(level: BusLevel, nodeId: string, port: number): BusStructure { + const connection = level.incoming.get(`${nodeId}:${port}`); + return connection ? structureOut(level, connection.sourceNodeId, connection.sourcePortIndex) : null; + } + + function structureOut(level: BusLevel, nodeId: string, port: number): BusStructure { + const key = `${level.id}:${nodeId}:${port}`; + if (memo.has(key)) return memo.get(key)!; + // A wire loop through bus blocks has no defined structure + if (visiting.has(key)) return null; + visiting.add(key); + const structure = computeOut(level, nodeId, port); + visiting.delete(key); + memo.set(key, structure); + return structure; + } + + function computeOut(level: BusLevel, nodeId: string, port: number): BusStructure { + const node = level.nodes.get(nodeId); + if (!node) return null; + switch (node.type) { + case NODE_TYPES.BUS_CREATOR: + return elementNames(level, node).map((name, i) => ({ name, structure: structureIn(level, node.id, i) })); + case NODE_TYPES.BUS_SELECTOR: { + const path = selectedSignals(node)[port]; + return path ? (elementAt(structureIn(level, node.id, 0), path)?.structure ?? null) : null; + } + case NODE_TYPES.SUBSYSTEM: { + const inner = level.children.get(node.id); + const iface = inner && interfaceOf(inner); + return inner && iface ? structureIn(inner, iface.id, port) : null; + } + case NODE_TYPES.INTERFACE: + return level.parent ? structureIn(level.parent.level, level.parent.subsystem.id, port) : null; + default: + return null; + } + } + + /** Level at a subsystem path from the root, by subsystem IDs */ + function levelAt(path: string[]): BusLevel | null { + let level: BusLevel | undefined = root; + for (const id of path) { + level = level?.children.get(id); + if (!level) return null; + } + return level; + } + + return { root, levelAt, structureIn, structureOut }; +} + +/** + * The model without bus blocks, for code generation. Models without bus blocks + * are returned unchanged. Connections that carry several signals are split, + * with IDs suffixed by the signal index. Wiring that cannot be resolved, such + * as a bus into a plain block or a signal missing from a bus, is left out. + */ +export function expandBuses( + nodes: NodeInstance[], + connections: Connection[] +): { nodes: NodeInstance[]; connections: Connection[] } { + if (!containsBusBlocks(nodes)) return { nodes, connections }; + + const { root, structureIn, structureOut } = analyzeBuses(nodes, connections); + const count = (structure: BusStructure) => signalLeaves(structure).length; + const range = (nodeId: string, offset: number, length: number): Endpoint[] => + Array.from({ length }, (_, j) => ({ nodeId, port: offset + j })); + + /** First expanded port index of each port of a subsystem */ + const offsetMemo = new Map(); + function offsets(level: BusLevel, subsystem: NodeInstance, direction: 'in' | 'out'): number[] { + const key = `${level.id}:${subsystem.id}:${direction}`; + const known = offsetMemo.get(key); + if (known) return known; + const ports = direction === 'in' ? subsystem.inputs.length : subsystem.outputs.length; + const result: number[] = []; + let sum = 0; + for (let i = 0; i < ports; i++) { + result.push(sum); + sum += count(direction === 'in' ? structureIn(level, subsystem.id, i) : structureOut(level, subsystem.id, i)); + } + offsetMemo.set(key, result); + return result; + } + + const resolving = new Set(); + + function resolveIn(level: BusLevel, nodeId: string, port: number): Endpoint[] { + const connection = level.incoming.get(`${nodeId}:${port}`); + return connection ? resolveOut(level, connection.sourceNodeId, connection.sourcePortIndex) : [null]; + } + + /** Real source of every signal leaving an output, in leaf order */ + function resolveOut(level: BusLevel, nodeId: string, port: number): Endpoint[] { + const key = `${level.id}:${nodeId}:${port}`; + if (resolving.has(key)) return [null]; + resolving.add(key); + const endpoints = computeResolveOut(level, nodeId, port); + resolving.delete(key); + return endpoints; + } + + function computeResolveOut(level: BusLevel, nodeId: string, port: number): Endpoint[] { + const node = level.nodes.get(nodeId); + if (!node) return [null]; + switch (node.type) { + case NODE_TYPES.BUS_CREATOR: + return node.inputs.flatMap((_, i) => resolveIn(level, node.id, i)); + case NODE_TYPES.BUS_SELECTOR: { + const path = selectedSignals(node)[port]; + const all = resolveIn(level, node.id, 0); + const leaves = signalLeaves(structureIn(level, node.id, 0)); + const picked = path + ? leaves.flatMap((leaf, i) => (leaf === path || leaf.startsWith(path + SEPARATOR) ? [all[i] ?? null] : [])) + : []; + return picked.length > 0 ? picked : [null]; + } + case NODE_TYPES.SUBSYSTEM: + return range(node.id, offsets(level, node, 'out')[port] ?? port, count(structureOut(level, node.id, port))); + case NODE_TYPES.INTERFACE: { + if (!level.parent) return [{ nodeId, port }]; + const { level: outer, subsystem } = level.parent; + return range(node.id, offsets(outer, subsystem, 'in')[port] ?? port, count(structureIn(outer, subsystem.id, port))); + } + default: + return [{ nodeId, port }]; + } + } + + /** Expanded input slots a connection into a port lands on; bus blocks take none */ + function inputSlots(level: BusLevel, node: NodeInstance, port: number): Endpoint[] { + switch (node.type) { + case NODE_TYPES.BUS_CREATOR: + case NODE_TYPES.BUS_SELECTOR: + return []; + case NODE_TYPES.SUBSYSTEM: + return range(node.id, offsets(level, node, 'in')[port] ?? port, count(structureIn(level, node.id, port))); + case NODE_TYPES.INTERFACE: { + if (!level.parent) return [{ nodeId: node.id, port }]; + const { level: outer, subsystem } = level.parent; + return range(node.id, offsets(outer, subsystem, 'out')[port] ?? port, count(structureOut(outer, subsystem.id, port))); + } + default: + return [{ nodeId: node.id, port }]; + } + } + + function expandLevel(level: BusLevel): { nodes: NodeInstance[]; connections: Connection[] } { + const expandedNodes: NodeInstance[] = []; + for (const node of level.nodeList) { + if (isBusBlock(node)) continue; + const inner = level.children.get(node.id); + if (inner && node.graph) { + const expanded = expandLevel(inner); + expandedNodes.push({ ...node, graph: { ...node.graph, nodes: expanded.nodes, connections: expanded.connections } }); + } else { + expandedNodes.push(node); + } + } + + const expandedConnections: Connection[] = []; + for (const connection of level.connections) { + const target = level.nodes.get(connection.targetNodeId); + if (!target || !level.nodes.has(connection.sourceNodeId)) { + expandedConnections.push(connection); + continue; + } + const targets = inputSlots(level, target, connection.targetPortIndex); + if (targets.length === 0) continue; + const sources = resolveOut(level, connection.sourceNodeId, connection.sourcePortIndex); + if (sources.length !== targets.length) continue; + sources.forEach((source, i) => { + const slot = targets[i]; + if (!source || !slot) return; + expandedConnections.push({ + ...connection, + id: sources.length === 1 ? connection.id : `${connection.id}${SEPARATOR}${i}`, + sourceNodeId: source.nodeId, + sourcePortIndex: source.port, + targetNodeId: slot.nodeId, + targetPortIndex: slot.port + }); + }); + } + return { nodes: expandedNodes, connections: expandedConnections }; + } + + return expandLevel(root); +} diff --git a/src/lib/constants/nodeTypes.ts b/src/lib/constants/nodeTypes.ts index dc841a67..e2d51710 100644 --- a/src/lib/constants/nodeTypes.ts +++ b/src/lib/constants/nodeTypes.ts @@ -1,10 +1,13 @@ /** * Centralized node type identifiers - * These match the PathSim block class names directly + * Subsystem and Interface match the PathSim block class names directly. + * Bus Creator and Bus Selector exist only in the editor and are resolved before code generation. */ export const NODE_TYPES = { SUBSYSTEM: 'Subsystem', - INTERFACE: 'Interface' + INTERFACE: 'Interface', + BUS_CREATOR: 'BusCreator', + BUS_SELECTOR: 'BusSelector' } as const; export type NodeTypeId = (typeof NODE_TYPES)[keyof typeof NODE_TYPES]; diff --git a/tests/fixtures/bus_expansion.json b/tests/fixtures/bus_expansion.json new file mode 100644 index 00000000..19aeba3a --- /dev/null +++ b/tests/fixtures/bus_expansion.json @@ -0,0 +1,221 @@ +{ + "description": "Bus expansion cases shared by src/lib/bus/expand.test.ts and tests/test_bus_expansion.py. Expected wiring per level as source:port>target:port; level keys are subsystem ID paths joined by '/', the root is ''.", + "scenarios": [ + { + "name": "flat creator and selector", + "nodes": [ + { "id": "A", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "B", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "C", "type": "BusCreator", "inputs": [{ "name": "in 0" }, { "name": "in 1" }], "outputs": [{ "name": "out 0" }] }, + { "id": "S", "type": "BusSelector", "inputs": [{ "name": "in 0" }], "outputs": [{ "name": "b" }, { "name": "out 0" }], "params": { "signals": ["b", "out 0"] } }, + { "id": "Scope", "type": "Scope", "inputs": [{ "name": "in 0" }, { "name": "in 1" }], "outputs": [] } + ], + "connections": [ + { "id": "c1", "sourceNodeId": "A", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 0 }, + { "id": "c2", "sourceNodeId": "B", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 1, "label": "b" }, + { "id": "c3", "sourceNodeId": "C", "sourcePortIndex": 0, "targetNodeId": "S", "targetPortIndex": 0 }, + { "id": "c4", "sourceNodeId": "S", "sourcePortIndex": 0, "targetNodeId": "Scope", "targetPortIndex": 0 }, + { "id": "c5", "sourceNodeId": "S", "sourcePortIndex": 1, "targetNodeId": "Scope", "targetPortIndex": 1 } + ], + "expected": { "": ["B:0>Scope:0", "A:0>Scope:1"] } + }, + { + "name": "duplicate names are numbered and fan-out is kept", + "nodes": [ + { "id": "A", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "B", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "C", "type": "BusCreator", "inputs": [{ "name": "in 0" }, { "name": "in 1" }], "outputs": [{ "name": "out 0" }] }, + { "id": "S", "type": "BusSelector", "inputs": [{ "name": "in 0" }], "outputs": [{ "name": "out 0_2" }], "params": { "signals": ["out 0_2"] } }, + { "id": "X", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] }, + { "id": "Y", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] } + ], + "connections": [ + { "id": "c1", "sourceNodeId": "A", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 0 }, + { "id": "c2", "sourceNodeId": "B", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 1 }, + { "id": "c3", "sourceNodeId": "C", "sourcePortIndex": 0, "targetNodeId": "S", "targetPortIndex": 0 }, + { "id": "c4", "sourceNodeId": "S", "sourcePortIndex": 0, "targetNodeId": "X", "targetPortIndex": 0 }, + { "id": "c5", "sourceNodeId": "S", "sourcePortIndex": 0, "targetNodeId": "Y", "targetPortIndex": 0 } + ], + "expected": { "": ["B:0>X:0", "B:0>Y:0"] } + }, + { + "name": "nested buses and selecting a sub-bus", + "nodes": [ + { "id": "A", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "B", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "D", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "C1", "type": "BusCreator", "inputs": [{ "name": "in 0" }, { "name": "in 1" }], "outputs": [{ "name": "out 0" }] }, + { "id": "C2", "type": "BusCreator", "inputs": [{ "name": "in 0" }, { "name": "in 1" }], "outputs": [{ "name": "out 0" }] }, + { "id": "S", "type": "BusSelector", "inputs": [{ "name": "in 0" }], "outputs": [{ "name": "inner.b" }, { "name": "d" }, { "name": "inner" }], "params": { "signals": ["inner.b", "d", "inner"] } }, + { "id": "S2", "type": "BusSelector", "inputs": [{ "name": "in 0" }], "outputs": [{ "name": "a" }], "params": { "signals": ["a"] } }, + { "id": "X", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] }, + { "id": "Y", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] }, + { "id": "Z", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] } + ], + "connections": [ + { "id": "c1", "sourceNodeId": "A", "sourcePortIndex": 0, "targetNodeId": "C1", "targetPortIndex": 0, "label": "a" }, + { "id": "c2", "sourceNodeId": "B", "sourcePortIndex": 0, "targetNodeId": "C1", "targetPortIndex": 1, "label": "b" }, + { "id": "c3", "sourceNodeId": "C1", "sourcePortIndex": 0, "targetNodeId": "C2", "targetPortIndex": 0, "label": "inner" }, + { "id": "c4", "sourceNodeId": "D", "sourcePortIndex": 0, "targetNodeId": "C2", "targetPortIndex": 1, "label": "d" }, + { "id": "c5", "sourceNodeId": "C2", "sourcePortIndex": 0, "targetNodeId": "S", "targetPortIndex": 0 }, + { "id": "c6", "sourceNodeId": "S", "sourcePortIndex": 0, "targetNodeId": "X", "targetPortIndex": 0 }, + { "id": "c7", "sourceNodeId": "S", "sourcePortIndex": 1, "targetNodeId": "Y", "targetPortIndex": 0 }, + { "id": "c8", "sourceNodeId": "S", "sourcePortIndex": 2, "targetNodeId": "S2", "targetPortIndex": 0 }, + { "id": "c9", "sourceNodeId": "S2", "sourcePortIndex": 0, "targetNodeId": "Z", "targetPortIndex": 0 } + ], + "expected": { "": ["B:0>X:0", "D:0>Y:0", "A:0>Z:0"] } + }, + { + "name": "bus into a subsystem", + "nodes": [ + { "id": "A", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "B", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "C", "type": "BusCreator", "inputs": [{ "name": "in 0" }, { "name": "in 1" }], "outputs": [{ "name": "out 0" }] }, + { + "id": "Sub", "type": "Subsystem", "inputs": [{ "name": "in 0" }], "outputs": [{ "name": "out 0" }], + "graph": { + "nodes": [ + { "id": "I", "type": "Interface", "inputs": [{ "name": "out 0" }], "outputs": [{ "name": "in 0" }] }, + { "id": "Sel", "type": "BusSelector", "inputs": [{ "name": "in 0" }], "outputs": [{ "name": "b" }], "params": { "signals": ["b"] } }, + { "id": "G", "type": "Amplifier", "inputs": [{ "name": "in 0" }], "outputs": [{ "name": "out 0" }] } + ], + "connections": [ + { "id": "s1", "sourceNodeId": "I", "sourcePortIndex": 0, "targetNodeId": "Sel", "targetPortIndex": 0 }, + { "id": "s2", "sourceNodeId": "Sel", "sourcePortIndex": 0, "targetNodeId": "G", "targetPortIndex": 0 }, + { "id": "s3", "sourceNodeId": "G", "sourcePortIndex": 0, "targetNodeId": "I", "targetPortIndex": 0 } + ] + } + }, + { "id": "Scope", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] } + ], + "connections": [ + { "id": "c1", "sourceNodeId": "A", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 0, "label": "a" }, + { "id": "c2", "sourceNodeId": "B", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 1, "label": "b" }, + { "id": "c3", "sourceNodeId": "C", "sourcePortIndex": 0, "targetNodeId": "Sub", "targetPortIndex": 0 }, + { "id": "c4", "sourceNodeId": "Sub", "sourcePortIndex": 0, "targetNodeId": "Scope", "targetPortIndex": 0 } + ], + "expected": { + "": ["A:0>Sub:0", "B:0>Sub:1", "Sub:0>Scope:0"], + "Sub": ["I:1>G:0", "G:0>I:0"] + } + }, + { + "name": "bus out of a subsystem", + "nodes": [ + { + "id": "Sub", "type": "Subsystem", "inputs": [], "outputs": [{ "name": "out 0" }], + "graph": { + "nodes": [ + { "id": "I", "type": "Interface", "inputs": [{ "name": "out 0" }], "outputs": [] }, + { "id": "G1", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "G2", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "C", "type": "BusCreator", "inputs": [{ "name": "in 0" }, { "name": "in 1" }], "outputs": [{ "name": "out 0" }] } + ], + "connections": [ + { "id": "s1", "sourceNodeId": "G1", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 0, "label": "x" }, + { "id": "s2", "sourceNodeId": "G2", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 1, "label": "y" }, + { "id": "s3", "sourceNodeId": "C", "sourcePortIndex": 0, "targetNodeId": "I", "targetPortIndex": 0 } + ] + } + }, + { "id": "Sel", "type": "BusSelector", "inputs": [{ "name": "in 0" }], "outputs": [{ "name": "y" }], "params": { "signals": ["y"] } }, + { "id": "Scope", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] } + ], + "connections": [ + { "id": "c1", "sourceNodeId": "Sub", "sourcePortIndex": 0, "targetNodeId": "Sel", "targetPortIndex": 0 }, + { "id": "c2", "sourceNodeId": "Sel", "sourcePortIndex": 0, "targetNodeId": "Scope", "targetPortIndex": 0 } + ], + "expected": { + "": ["Sub:1>Scope:0"], + "Sub": ["G1:0>I:0", "G2:0>I:1"] + } + }, + { + "name": "bus through two subsystem levels", + "nodes": [ + { "id": "A", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "B", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "C", "type": "BusCreator", "inputs": [{ "name": "in 0" }, { "name": "in 1" }], "outputs": [{ "name": "out 0" }] }, + { + "id": "Outer", "type": "Subsystem", "inputs": [{ "name": "in 0" }], "outputs": [], + "graph": { + "nodes": [ + { "id": "I1", "type": "Interface", "inputs": [], "outputs": [{ "name": "in 0" }] }, + { + "id": "Inner", "type": "Subsystem", "inputs": [{ "name": "in 0" }], "outputs": [], + "graph": { + "nodes": [ + { "id": "I2", "type": "Interface", "inputs": [], "outputs": [{ "name": "in 0" }] }, + { "id": "Sel", "type": "BusSelector", "inputs": [{ "name": "in 0" }], "outputs": [{ "name": "a" }], "params": { "signals": ["a"] } }, + { "id": "G", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] } + ], + "connections": [ + { "id": "i1", "sourceNodeId": "I2", "sourcePortIndex": 0, "targetNodeId": "Sel", "targetPortIndex": 0 }, + { "id": "i2", "sourceNodeId": "Sel", "sourcePortIndex": 0, "targetNodeId": "G", "targetPortIndex": 0 } + ] + } + } + ], + "connections": [ + { "id": "o1", "sourceNodeId": "I1", "sourcePortIndex": 0, "targetNodeId": "Inner", "targetPortIndex": 0 } + ] + } + } + ], + "connections": [ + { "id": "c1", "sourceNodeId": "A", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 0, "label": "a" }, + { "id": "c2", "sourceNodeId": "B", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 1, "label": "b" }, + { "id": "c3", "sourceNodeId": "C", "sourcePortIndex": 0, "targetNodeId": "Outer", "targetPortIndex": 0 } + ], + "expected": { + "": ["A:0>Outer:0", "B:0>Outer:1"], + "Outer": ["I1:0>Inner:0", "I1:1>Inner:1"], + "Outer/Inner": ["I2:0>G:0"] + } + }, + { + "name": "unresolvable wiring is left out", + "nodes": [ + { "id": "A", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "C", "type": "BusCreator", "inputs": [{ "name": "in 0" }, { "name": "in 1" }], "outputs": [{ "name": "out 0" }] }, + { "id": "Sel", "type": "BusSelector", "inputs": [{ "name": "in 0" }], "outputs": [{ "name": "a" }, { "name": "missing" }], "params": { "signals": ["a", "missing"] } }, + { "id": "X", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] }, + { "id": "Y", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] }, + { "id": "Scope", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] } + ], + "connections": [ + { "id": "c1", "sourceNodeId": "A", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 0, "label": "a" }, + { "id": "c2", "sourceNodeId": "C", "sourcePortIndex": 0, "targetNodeId": "Sel", "targetPortIndex": 0 }, + { "id": "c3", "sourceNodeId": "Sel", "sourcePortIndex": 0, "targetNodeId": "X", "targetPortIndex": 0 }, + { "id": "c4", "sourceNodeId": "Sel", "sourcePortIndex": 1, "targetNodeId": "Y", "targetPortIndex": 0 }, + { "id": "c5", "sourceNodeId": "C", "sourcePortIndex": 0, "targetNodeId": "Scope", "targetPortIndex": 0 } + ], + "expected": { "": ["A:0>X:0"] } + }, + { + "name": "wire loop through bus blocks terminates", + "nodes": [ + { "id": "A", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "C", "type": "BusCreator", "inputs": [{ "name": "in 0" }, { "name": "in 1" }], "outputs": [{ "name": "out 0" }] }, + { "id": "S", "type": "BusSelector", "inputs": [{ "name": "in 0" }], "outputs": [{ "name": "loop" }], "params": { "signals": ["loop"] } } + ], + "connections": [ + { "id": "c1", "sourceNodeId": "A", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 0, "label": "a" }, + { "id": "c2", "sourceNodeId": "C", "sourcePortIndex": 0, "targetNodeId": "S", "targetPortIndex": 0 }, + { "id": "c3", "sourceNodeId": "S", "sourcePortIndex": 0, "targetNodeId": "C", "targetPortIndex": 1, "label": "loop" } + ], + "expected": { "": [] } + }, + { + "name": "model without buses is unchanged", + "nodes": [ + { "id": "A", "type": "Constant", "inputs": [], "outputs": [{ "name": "out 0" }] }, + { "id": "X", "type": "Scope", "inputs": [{ "name": "in 0" }], "outputs": [] } + ], + "connections": [ + { "id": "c1", "sourceNodeId": "A", "sourcePortIndex": 0, "targetNodeId": "X", "targetPortIndex": 0 } + ], + "expected": { "": ["A:0>X:0"] } + } + ] +} diff --git a/tests/test_bus_expansion.py b/tests/test_bus_expansion.py new file mode 100644 index 00000000..d417042c --- /dev/null +++ b/tests/test_bus_expansion.py @@ -0,0 +1,66 @@ +"""Bus expansion: the Python converter resolves bus blocks like the editor does.""" + +import json +from pathlib import Path + +import pytest + +from pathview.buses import expand_buses, is_bus_block +from pathview.converter import generate_python, load_registry + +FIXTURES = json.loads((Path(__file__).parent / "fixtures" / "bus_expansion.json").read_text()) +REGISTRY_PATH = Path(__file__).parent.parent / "pathview" / "data" / "registry.json" + + +def _wiring(connections): + return sorted( + f"{c['sourceNodeId']}:{c['sourcePortIndex']}>{c['targetNodeId']}:{c['targetPortIndex']}" + for c in connections + ) + + +def _levels(nodes, connections, path=""): + """Wiring per level, keyed by subsystem ID path.""" + result = {path: _wiring(connections)} + for node in nodes: + graph = node.get("graph") + if graph: + child = f"{path}/{node['id']}" if path else node["id"] + result.update(_levels(graph.get("nodes", []), graph.get("connections", []), child)) + return result + + +def _any_bus_block(nodes): + return any( + is_bus_block(n) or (bool(n.get("graph")) and _any_bus_block(n["graph"].get("nodes", []))) + for n in nodes + ) + + +@pytest.mark.parametrize("scenario", FIXTURES["scenarios"], ids=lambda s: s["name"]) +def test_expansion_matches_fixture(scenario): + nodes, connections = expand_buses(scenario["nodes"], scenario["connections"]) + expected = {key: sorted(value) for key, value in scenario["expected"].items()} + assert _levels(nodes, connections) == expected + assert not _any_bus_block(nodes) + + +def test_model_without_buses_is_unchanged(): + scenario = next(s for s in FIXTURES["scenarios"] if "without buses" in s["name"]) + nodes, connections = expand_buses(scenario["nodes"], scenario["connections"]) + assert nodes is scenario["nodes"] + assert connections is scenario["connections"] + + +def test_converter_generates_no_bus_blocks(): + scenario = next(s for s in FIXTURES["scenarios"] if s["name"] == "bus into a subsystem") + pvm = {"version": "1.0.0", "graph": {"nodes": scenario["nodes"], "connections": scenario["connections"]}} + for node in pvm["graph"]["nodes"]: + node.setdefault("name", node["id"]) + node.setdefault("params", {}) + for child in (node.get("graph") or {}).get("nodes", []): + child.setdefault("name", child["id"]) + child.setdefault("params", {}) + code = generate_python(pvm, load_registry(REGISTRY_PATH)) + assert "BusCreator" not in code + assert "BusSelector" not in code From 72af035312906808925112ff51f9633c39ef046f Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 17:33:35 +0200 Subject: [PATCH 03/17] Add Bus Creator and Bus Selector blocks with signal picking and bus wires --- src/app.css | 27 +++++++++ src/lib/bus/expand.test.ts | 2 +- src/lib/bus/expand.ts | 14 +++++ src/lib/components/FlowCanvas.svelte | 21 +++++-- src/lib/components/canvas/flowConverters.ts | 4 +- .../dialogs/BlockPropertiesDialog.svelte | 56 ++++++++++++++++++- .../components/edges/OrthogonalEdge.svelte | 11 +++- src/lib/constants/dimensions.ts | 6 ++ src/lib/nodes/buses.ts | 49 ++++++++++++++++ src/lib/nodes/index.ts | 6 ++ src/lib/pyodide/mutationQueue.ts | 4 +- src/lib/pyodide/pathsimRunner.ts | 20 +++++-- src/lib/stores/graph/buses.ts | 50 +++++++++++++++++ src/lib/stores/graph/index.ts | 4 ++ 14 files changed, 259 insertions(+), 15 deletions(-) create mode 100644 src/lib/nodes/buses.ts create mode 100644 src/lib/stores/graph/buses.ts diff --git a/src/app.css b/src/app.css index 85c5edae..6efbeaf9 100644 --- a/src/app.css +++ b/src/app.css @@ -603,6 +603,33 @@ input[type="checkbox"]:focus-visible { padding: var(--space-md); } +/* Bus Selector signal list; nested bus elements are indented by depth */ +.properties-dialog .signal-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.properties-dialog .signal-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding-left: calc(var(--signal-depth, 0) * var(--space-lg)); + font-size: 12px; + font-family: var(--font-mono); + color: var(--text); + cursor: pointer; +} + +.properties-dialog .signal-item .bus-signal { + color: var(--text-muted); +} + +.properties-dialog .signal-item.missing { + color: var(--text-disabled); + text-decoration: line-through; +} + .properties-dialog .dialog-footer { padding: var(--space-sm) var(--space-md); background: var(--surface-raised); diff --git a/src/lib/bus/expand.test.ts b/src/lib/bus/expand.test.ts index 1352d1ff..0922952d 100644 --- a/src/lib/bus/expand.test.ts +++ b/src/lib/bus/expand.test.ts @@ -35,7 +35,7 @@ describe('bus expansion', () => { const { nodes, connections } = load(scenario); const expanded = expandBuses(nodes, connections); const expected = Object.fromEntries( - Object.entries(scenario.expected as Record).map(([key, value]) => [key, [...value].sort()]) + Object.entries(scenario.expected as unknown as Record).map(([key, value]) => [key, [...value].sort()]) ); expect(levels(expanded.nodes, expanded.connections)).toEqual(expected); expect(anyBusBlock(expanded.nodes)).toBe(false); diff --git a/src/lib/bus/expand.ts b/src/lib/bus/expand.ts index 6a92e1bb..59bbf4c0 100644 --- a/src/lib/bus/expand.ts +++ b/src/lib/bus/expand.ts @@ -59,6 +59,20 @@ export function signalLeaves(structure: BusStructure): string[] { ); } +/** Every signal path in a bus with its nesting depth; sub-buses come before their elements */ +export function signalPaths(structure: BusStructure): { path: string; depth: number; isBus: boolean }[] { + const paths: { path: string; depth: number; isBus: boolean }[] = []; + const walk = (elements: BusElement[], prefix: string, depth: number) => { + for (const element of elements) { + const path = prefix ? `${prefix}${SEPARATOR}${element.name}` : element.name; + paths.push({ path, depth, isBus: element.structure !== null }); + if (element.structure) walk(element.structure, path, depth + 1); + } + }; + if (structure) walk(structure, '', 0); + return paths; +} + /** Element at a dotted signal path */ export function elementAt(structure: BusStructure, path: string): BusElement | undefined { let current: BusElement | undefined; diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 5d3be6e8..bbfe432f 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -38,6 +38,7 @@ import { NODE_TYPES } from '$lib/constants/nodeTypes'; import { GRID_SIZE, SNAP_GRID, BACKGROUND_GAP } from '$lib/constants/grid'; import { createRoutingSync } from './canvas/routingSync'; + import { analyzeBuses } from '$lib/bus/expand'; import { createEdgeHighlighter } from '$lib/stores/edgeHighlight'; import { CANVAS_MIN_ZOOM } from '$lib/constants/layout'; import { shallowEqualArray, shallowEqualRecord } from '$lib/utils/shallowEqual'; @@ -602,13 +603,27 @@ const edgeHighlighter = createEdgeHighlighter(() => edges); cleanups.push(edgeHighlighter.destroy); + // Connections of the current level that carry a bus; bus structure follows wires across all levels + function busWireIds(connections: Connection[]): Set { + const model = graphStore.toJSON(); + const analysis = analyzeBuses(model.nodes, model.connections); + const level = analysis.levelAt(graphStore.getCurrentPath()); + if (!level) return new Set(); + return new Set( + connections + .filter((c) => analysis.structureOut(level, c.sourceNodeId, c.sourcePortIndex) !== null) + .map((c) => c.id) + ); + } + function rebuildEdges(connections: Connection[]): void { const visibleIds = getVisibleNodeIds(); const currentEdgeSelection = new Map(edges.map((e) => [e.id, e.selected])); + const busWires = busWireIds(connections); edges = connections .filter((c) => visibleIds.has(c.sourceNodeId) && visibleIds.has(c.targetNodeId)) .map((conn) => { - const edge = toFlowEdge(conn); + const edge = toFlowEdge(conn, busWires.has(conn.id)); if (currentEdgeSelection.get(conn.id)) edge.selected = true; return edge; }); @@ -766,9 +781,7 @@ annotationNodes = annotationNodes.filter(n => !deletedIds.has(n.id)); // Force sync edges from store after deletion - const afterConnections = get(graphStore.connections); - edges = afterConnections.map(toFlowEdge); - edgeHighlighter.refresh(); + rebuildEdges(get(graphStore.connections)); isSyncing = false; } diff --git a/src/lib/components/canvas/flowConverters.ts b/src/lib/components/canvas/flowConverters.ts index 712ebc09..28b529c7 100644 --- a/src/lib/components/canvas/flowConverters.ts +++ b/src/lib/components/canvas/flowConverters.ts @@ -48,7 +48,7 @@ export function toAnnotationNode(annotation: Annotation): Node { /** * Convert a Connection to a SvelteFlow Edge */ -export function toFlowEdge(conn: Connection): Edge { +export function toFlowEdge(conn: Connection, carriesBus = false): Edge { return { id: conn.id, source: conn.sourceNodeId, @@ -56,7 +56,7 @@ export function toFlowEdge(conn: Connection): Edge { target: conn.targetNodeId, targetHandle: HANDLE_ID.input(conn.targetNodeId, conn.targetPortIndex), type: 'orthogonal', - data: { waypoints: conn.waypoints, label: conn.label }, + data: { waypoints: conn.waypoints, label: conn.label, bus: carriesBus }, selectable: true, deletable: true, animated: false diff --git a/src/lib/components/dialogs/BlockPropertiesDialog.svelte b/src/lib/components/dialogs/BlockPropertiesDialog.svelte index ebff1051..c9e3e1d3 100644 --- a/src/lib/components/dialogs/BlockPropertiesDialog.svelte +++ b/src/lib/components/dialogs/BlockPropertiesDialog.svelte @@ -23,6 +23,7 @@ import { createRecordingDataState } from '$lib/stores/recordingData.svelte'; import { getPortLabelConfigs } from '$lib/nodes/uiConfig'; import { PORT_NAME } from '$lib/constants/handles'; + import { analyzeBuses, selectedSignals, signalPaths } from '$lib/bus/expand'; // Code preview state (declared early — referenced by subscription below) let showCode = $state(false); @@ -93,6 +94,33 @@ // Get current color for display const currentColor = $derived(node?.color || DEFAULT_NODE_COLOR); + // Bus Selector: signals on the bus at its input, and the ones it picks + const isBusSelector = $derived(node?.type === NODE_TYPES.BUS_SELECTOR); + const busSignalOptions = $derived.by(() => { + if (!node || node.type !== NODE_TYPES.BUS_SELECTOR) return []; + const model = graphStore.toJSON(); + const analysis = analyzeBuses(model.nodes, model.connections); + const level = analysis.levelAt(graphStore.getCurrentPath()); + return level ? signalPaths(analysis.structureIn(level, node.id, 0)) : []; + }); + const pickedSignals = $derived(node ? selectedSignals(node) : []); + // Picked signals the bus no longer carries stay listed until they are unpicked + const missingSignals = $derived(pickedSignals.filter((s) => !busSignalOptions.some((o) => o.path === s))); + + function toggleSignal(path: string, picked: boolean) { + if (!node) return; + const id = node.id; + const chosen = new Set(pickedSignals); + if (picked) chosen.add(path); + else chosen.delete(path); + // Outputs keep the order of the bus + const ordered = [ + ...busSignalOptions.map((o) => o.path).filter((p) => chosen.has(p)), + ...missingSignals.filter((p) => chosen.has(p)) + ]; + historyStore.mutate(() => graphStore.setSelectedSignals(id, ordered)); + } + // Handle color selection function handleColorSelect(color: string | undefined) { if (!node) return; @@ -440,7 +468,33 @@ {/if} {:else} - {#if typeDef.params.length > 0} + {#if isBusSelector} +
+
Signals
+ {#if busSignalOptions.length === 0 && missingSignals.length === 0} +
Connect a bus to the input to pick signals
+ {:else} +
+ {#each busSignalOptions as option (option.path)} + + {/each} + {#each missingSignals as path (path)} + + {/each} +
+ {/if} +
+ {:else if typeDef.params.length > 0}
Parameters
diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 0c6a5e13..b1814da5 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -35,6 +35,7 @@ import { screenToFlow } from '$lib/utils/viewUtils'; import { GRID_SIZE, EDGE_SOURCE_OFFSET, EDGE_TARGET_OFFSET, EDGE_CORNER_RADIUS } from '$lib/routing/constants'; import InlineInput from '$lib/components/InlineInput.svelte'; + import { BUS_WIRE } from '$lib/constants/dimensions'; import type { Direction, RouteResult } from '$lib/routing'; import type { Waypoint } from '$lib/types/nodes'; @@ -298,6 +299,9 @@ return midpoints; }); + // Wires carrying a bus are drawn thicker + const carriesBus = $derived(Boolean((data as { bus?: boolean } | undefined)?.bus)); + // Connection label, shown on the middle of the longest route segment const label = $derived((data as { label?: string } | undefined)?.label ?? ''); const isEditingLabel = $derived(edgeLabelEdit.connectionId === id); @@ -380,7 +384,8 @@ @@ -464,6 +469,10 @@ fill: var(--accent); } + .bus-wire :global(.svelte-flow__edge-path) { + stroke-width: var(--bus-wire-width); + } + /* Highlight the edge path when handle is hovered */ .highlighted :global(.svelte-flow__edge-path) { stroke: var(--highlight-color, var(--accent)) !important; diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index 63b4bce6..5bce3620 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -50,6 +50,12 @@ export const INLINE_INPUT = { maxSuggestions: 8 } as const; +/** Wire carrying a bus */ +export const BUS_WIRE = { + /** Line width in pixels */ + strokeWidth: 3 +} as const; + /** Event node dimensions (grid-aligned) */ export const EVENT = { /** Total bounding box size: 8 grid units = 80px */ diff --git a/src/lib/nodes/buses.ts b/src/lib/nodes/buses.ts new file mode 100644 index 00000000..dbec2e8f --- /dev/null +++ b/src/lib/nodes/buses.ts @@ -0,0 +1,49 @@ +/** + * Bus block definitions + * Bus Creator and Bus Selector exist only in the editor; they are resolved + * into direct wiring before code generation (see $lib/bus/expand). + */ + +import { defineNode } from './defineNode'; +import { nodeRegistry } from './registry'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; + +/** Bus Creator - bundles its input signals into one bus */ +export const BusCreatorDefinition = defineNode({ + name: 'Bus Creator', + category: 'Subsystem', + blockClass: NODE_TYPES.BUS_CREATOR, + description: + 'Bundles its input signals into one bus. A signal is named after the label of its wire, or else the port it comes from.', + inputs: ['in 0', 'in 1'], + outputs: ['bus'], + minInputs: 1, + maxInputs: null, + minOutputs: 1, + maxOutputs: 1, + shape: 'rect', + params: {} +}); + +/** Bus Selector - picks signals out of a bus; its outputs follow the picked signals */ +export const BusSelectorDefinition = defineNode({ + name: 'Bus Selector', + category: 'Subsystem', + blockClass: NODE_TYPES.BUS_SELECTOR, + description: 'Picks signals out of a bus by name, one output per picked signal.', + inputs: ['bus'], + outputs: [], + minInputs: 1, + maxInputs: 1, + minOutputs: 0, + maxOutputs: 0, + shape: 'rect', + params: { + signals: { type: 'any', default: [], description: 'Signal paths picked from the bus, one output each' } + } +}); + +export function registerBusNodes(): void { + nodeRegistry.register(BusCreatorDefinition); + nodeRegistry.register(BusSelectorDefinition); +} diff --git a/src/lib/nodes/index.ts b/src/lib/nodes/index.ts index 3561e69d..dcd3a7b8 100644 --- a/src/lib/nodes/index.ts +++ b/src/lib/nodes/index.ts @@ -21,3 +21,9 @@ registerSubsystemNodes(); // Re-export subsystem definitions export { SubsystemDefinition, InterfaceDefinition } from './subsystem'; + +// Register editor-only bus blocks +import { registerBusNodes } from './buses'; +registerBusNodes(); + +export { BusCreatorDefinition, BusSelectorDefinition } from './buses'; diff --git a/src/lib/pyodide/mutationQueue.ts b/src/lib/pyodide/mutationQueue.ts index f9a3d79a..b0cfe344 100644 --- a/src/lib/pyodide/mutationQueue.ts +++ b/src/lib/pyodide/mutationQueue.ts @@ -20,6 +20,7 @@ import { writable } from 'svelte/store'; import type { NodeInstance, Connection } from '$lib/nodes/types'; import { nodeRegistry } from '$lib/nodes/registry'; import { isSubsystem } from '$lib/nodes/shapes'; +import { isBusBlock } from '$lib/bus/expand'; import { sanitizeName } from './codeBuilder'; // --- Command types --- @@ -143,7 +144,8 @@ export function hasPendingMutations(): boolean { */ export function queueAddBlock(node: NodeInstance): void { if (!isActive()) return; - if (isSubsystem(node)) return; + // Bus blocks have no pathsim counterpart; wiring through them applies on the next run + if (isSubsystem(node) || isBusBlock(node)) return; const typeDef = nodeRegistry.get(node.type); if (!typeDef) return; diff --git a/src/lib/pyodide/pathsimRunner.ts b/src/lib/pyodide/pathsimRunner.ts index 338b95a6..ac8e42b3 100644 --- a/src/lib/pyodide/pathsimRunner.ts +++ b/src/lib/pyodide/pathsimRunner.ts @@ -15,6 +15,7 @@ import { blockImportPaths } from '$lib/nodes/generated/blocks'; import { ENGINE_MODULE, enginePath } from '$lib/constants/engine'; import { generateEngineSetup } from './engineCodegen'; import { graphStore, findParentSubsystem } from '$lib/stores/graph'; +import { expandBuses, isBusBlock } from '$lib/bus/expand'; import { runStreamingSimulation, validateGraph as validateGraphBridge, @@ -788,8 +789,9 @@ export async function runGraphStreamingSimulation( events: EventInstance[] = [], onUpdate?: (result: SimulationResult) => void ): Promise { - // Generate code without sim.run() - streaming will handle execution - const result = generatePythonCode(nodes, connections, settings, codeContext, true, events, false); + // Generate code without sim.run() - streaming will handle execution; bus blocks are wired directly + const model = expandBuses(nodes, connections); + const result = generatePythonCode(model.nodes, model.connections, settings, codeContext, true, events, false); const duration = getSettingOrDefault(settings, 'duration'); return runStreamingSimulation(result.code, String(duration), onUpdate, result.nodeVars, result.connVars); } @@ -804,7 +806,8 @@ export function exportToPython( codeContext: string, events: EventInstance[] = [] ): string { - return generateFormattedPythonCode(nodes, connections, settings, codeContext, events); + const model = expandBuses(nodes, connections); + return generateFormattedPythonCode(model.nodes, model.connections, settings, codeContext, events); } /** @@ -819,6 +822,10 @@ export function generateBlockCode( const typeDef = nodeRegistry.get(node.type); if (!typeDef) return ''; + if (isBusBlock(node)) { + return '# Bus blocks exist only in the editor; the generated code wires their signals directly'; + } + // Handle Interface blocks - generate parent Subsystem code instead if (node.type === NODE_TYPES.INTERFACE) { const rootNodes = allNodes || graphStore.getAllNodes(); @@ -837,7 +844,9 @@ export function generateBlockCode( const nodeVars = new Map(); const varNames: string[] = []; - generateSubsystemCode(node, nodeVars, varNames, lines, '', { formatted: true }); + // Buses entering from outside the subsystem are not known here; buses inside it are resolved + const [expanded] = expandBuses([node], []).nodes; + generateSubsystemCode(expanded, nodeVars, varNames, lines, '', { formatted: true }); return lines.join('\n'); } @@ -878,7 +887,8 @@ function extractNodeParams(nodes: NodeInstance[]): Record p.name)); const nodeParams: Record = {}; diff --git a/src/lib/stores/graph/buses.ts b/src/lib/stores/graph/buses.ts new file mode 100644 index 00000000..67822778 --- /dev/null +++ b/src/lib/stores/graph/buses.ts @@ -0,0 +1,50 @@ +/** + * Graph store - Bus block operations + */ + +import type { NodeInstance } from '$lib/nodes/types'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; +import { selectedSignals } from '$lib/bus/expand'; +import { queueRemoveConnection } from '$lib/pyodide/mutationQueue'; +import { createPorts } from './helpers'; +import { getCurrentGraph, updateCurrentNodesAndConnections } from './state'; + +/** + * Set the signals a Bus Selector picks. Its outputs follow the list, one per + * signal and named after it. Wires stay on their signal when the list changes; + * wires of signals no longer picked are removed. + */ +export function setSelectedSignals(nodeId: string, signals: string[]): void { + const graph = getCurrentGraph(); + const node = graph.nodes.get(nodeId); + if (!node || node.type !== NODE_TYPES.BUS_SELECTOR) return; + + const previous = selectedSignals(node); + const nextIndex = new Map(signals.map((signal, i) => [signal, i])); + const outputs = createPorts(nodeId, 'output', signals.map((name) => ({ name }))); + const update = (n: NodeInstance): NodeInstance => + n.id === nodeId ? { ...n, params: { ...n.params, signals }, outputs } : n; + + const connections = graph.connections.flatMap((c) => { + if (c.sourceNodeId !== nodeId) return [c]; + const signal = previous[c.sourcePortIndex]; + const port = signal === undefined ? undefined : nextIndex.get(signal); + if (port === undefined) { + queueRemoveConnection(c.id); + return []; + } + return port === c.sourcePortIndex ? [c] : [{ ...c, sourcePortIndex: port }]; + }); + + updateCurrentNodesAndConnections( + (nodes) => { + const current = nodes.get(nodeId); + if (!current) return nodes; + const next = new Map(nodes); + next.set(nodeId, update(current)); + return next; + }, + (nodes) => nodes.map(update), + () => connections + ); +} diff --git a/src/lib/stores/graph/index.ts b/src/lib/stores/graph/index.ts index e5a91fab..c44d8216 100644 --- a/src/lib/stores/graph/index.ts +++ b/src/lib/stores/graph/index.ts @@ -31,6 +31,7 @@ import * as nodes from './nodes'; import * as connections from './connections'; import * as ports from './ports'; import * as annotations from './annotations'; +import * as buses from './buses'; import * as subsystemEvents from './subsystemEvents'; import * as selection from './selection'; import * as serialization from './serialization'; @@ -94,6 +95,9 @@ export const graphStore = { removeAnnotation: annotations.removeAnnotation, getAnnotation: annotations.getAnnotation, + // ==================== BUS BLOCK OPERATIONS ==================== + setSelectedSignals: buses.setSelectedSignals, + // ==================== SUBSYSTEM EVENT OPERATIONS ==================== addSubsystemEvent: subsystemEvents.addSubsystemEvent, removeSubsystemEvent: subsystemEvents.removeSubsystemEvent, From 16bbaeb1b944b612e170e2008173a21856b1ee99 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 17:35:08 +0200 Subject: [PATCH 04/17] Document bus blocks and their resolution in the pvm spec --- docs/pvm-spec.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/pvm-spec.md b/docs/pvm-spec.md index db717de4..4d68e68f 100644 --- a/docs/pvm-spec.md +++ b/docs/pvm-spec.md @@ -23,6 +23,7 @@ This document is the authoritative reference for anyone building tools that read - [4.1 Subsystem Node](#41-subsystem-node) - [4.2 Interface Node](#42-interface-node) - [4.3 Nesting](#43-nesting) + - [4.4 Buses](#44-buses) - [5. Events](#5-events) - [6. Code Context](#6-code-context) - [7. Simulation Settings](#7-simulation-settings) @@ -301,6 +302,26 @@ Subsystems can be nested arbitrarily deep. A subsystem's `graph.nodes` can conta In PathSim Python code, subsystems map to `Subsystem(blocks=[...], connections=[...])` constructors. The Interface maps to `Interface()`. See `scripts/pvm2py.py` for a reference implementation. +### 4.4 Buses + +`BusCreator` and `BusSelector` nodes group signals into one wire. They exist only in the editor: code generators resolve them before generating code and never emit them. + +| Node type | Ports | Params | +|-----------|-------|--------| +| `BusCreator` | Any number of inputs, one output carrying the bus. | none | +| `BusSelector` | One input taking a bus, one output per picked signal. | `signals`: array of signal paths, one per output. | + +**Signal names.** Each Bus Creator input becomes a named element of the bus: the `label` of the incoming connection, else the name of the source port, else the input port name. Dots are replaced by underscores and repeated names get `_2`, `_3`, and so on. A bus fed into a Bus Creator becomes a nested bus; its signals are addressed with dotted paths such as `inner.b`. A path naming a nested bus selects the whole nested bus. + +**Resolution.** Code generators rewrite the model before generating code: + +- Bus Creator and Bus Selector nodes are removed, together with the connections into them. +- Each connection out of a Bus Selector output is wired from the original source of the picked signal. +- 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. +- 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. + +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. + --- ## 5. Events From a4c091ecb1c9e995f1f3f9056841b2c889f81268 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 17:57:49 +0200 Subject: [PATCH 05/17] Draw bus blocks as wedges and bus wires with signal counts, share port rendering with blocks --- src/lib/bus/expand.ts | 4 +- src/lib/components/FlowCanvas.svelte | 32 +-- src/lib/components/canvas/flowConverters.ts | 12 +- src/lib/components/canvas/routingSync.ts | 3 +- .../components/edges/OrthogonalEdge.svelte | 76 +++++- src/lib/components/nodes/BaseNode.svelte | 215 +---------------- src/lib/components/nodes/BusBlockNode.svelte | 142 +++++++++++ src/lib/components/nodes/NodePorts.svelte | 226 ++++++++++++++++++ src/lib/constants/dimensions.ts | 27 ++- src/lib/stores/busView.svelte.ts | 60 +++++ 10 files changed, 553 insertions(+), 244 deletions(-) create mode 100644 src/lib/components/nodes/BusBlockNode.svelte create mode 100644 src/lib/components/nodes/NodePorts.svelte create mode 100644 src/lib/stores/busView.svelte.ts diff --git a/src/lib/bus/expand.ts b/src/lib/bus/expand.ts index 59bbf4c0..fef1f44d 100644 --- a/src/lib/bus/expand.ts +++ b/src/lib/bus/expand.ts @@ -85,7 +85,7 @@ export function elementAt(structure: BusStructure, path: string): BusElement | u return current; } -function containsBusBlocks(nodes: NodeInstance[]): boolean { +export function containsBusBlocks(nodes: NodeInstance[]): boolean { return nodes.some((n) => isBusBlock(n) || (n.graph ? containsBusBlocks(n.graph.nodes) : false)); } @@ -193,7 +193,7 @@ export function analyzeBuses(nodes: NodeInstance[], connections: Connection[]) { return level; } - return { root, levelAt, structureIn, structureOut }; + return { root, levelAt, structureIn, structureOut, elementNames }; } /** diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index bbfe432f..b8afa3c7 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -38,7 +38,9 @@ import { NODE_TYPES } from '$lib/constants/nodeTypes'; import { GRID_SIZE, SNAP_GRID, BACKGROUND_GAP } from '$lib/constants/grid'; import { createRoutingSync } from './canvas/routingSync'; - import { analyzeBuses } from '$lib/bus/expand'; + import { isBusBlock } from '$lib/bus/expand'; + import { updateBusView } from '$lib/stores/busView.svelte'; + import BusBlockNode from './nodes/BusBlockNode.svelte'; import { createEdgeHighlighter } from '$lib/stores/edgeHighlight'; import { CANVAS_MIN_ZOOM } from '$lib/constants/layout'; import { shallowEqualArray, shallowEqualRecord } from '$lib/utils/shallowEqual'; @@ -50,6 +52,7 @@ toFlowEdge, toEventNode, toAnnotationNode, + isBlockFlowNode, rotateSelectedNodes, flipSelectedNodesHorizontal, flipSelectedNodesVertical, @@ -272,7 +275,7 @@ // Routing: the scene is diffed here and routed by the routing engine in a worker const routingSync = createRoutingSync({ - blockNodes: () => nodes.filter((n) => n.type === 'pathview'), + blockNodes: () => nodes.filter(isBlockFlowNode), node: (id) => nodeMap.get(id), connections: () => get(graphStore.connections), visibleBounds @@ -290,6 +293,7 @@ // Custom node types - will add more for different shapes const nodeTypes: NodeTypes = { pathview: BaseNode, + busBlock: BusBlockNode, eventNode: EventNode, annotation: AnnotationNode }; @@ -335,7 +339,7 @@ $effect(() => { const changed = new Set(); for (const node of nodes) { - if (node.type !== 'pathview') continue; + if (!isBlockFlowNode(node)) continue; const w = node.measured?.width; const h = node.measured?.height; if (w === undefined || h === undefined) continue; @@ -513,7 +517,7 @@ // New node return { id: graphNode.id, - type: 'pathview', + type: isBusBlock(graphNode) ? 'busBlock' : 'pathview', position, data: graphNode, // Explicit center origin for correct bounds calculation @@ -603,27 +607,15 @@ const edgeHighlighter = createEdgeHighlighter(() => edges); cleanups.push(edgeHighlighter.destroy); - // Connections of the current level that carry a bus; bus structure follows wires across all levels - function busWireIds(connections: Connection[]): Set { - const model = graphStore.toJSON(); - const analysis = analyzeBuses(model.nodes, model.connections); - const level = analysis.levelAt(graphStore.getCurrentPath()); - if (!level) return new Set(); - return new Set( - connections - .filter((c) => analysis.structureOut(level, c.sourceNodeId, c.sourcePortIndex) !== null) - .map((c) => c.id) - ); - } - function rebuildEdges(connections: Connection[]): void { const visibleIds = getVisibleNodeIds(); const currentEdgeSelection = new Map(edges.map((e) => [e.id, e.selected])); - const busWires = busWireIds(connections); + // Bus wires and bus block signal names follow the same graph changes as the edges + updateBusView(graphStore.toJSON(), graphStore.getCurrentPath(), connections); edges = connections .filter((c) => visibleIds.has(c.sourceNodeId) && visibleIds.has(c.targetNodeId)) .map((conn) => { - const edge = toFlowEdge(conn, busWires.has(conn.id)); + const edge = toFlowEdge(conn); if (currentEdgeSelection.get(conn.id)) edge.selected = true; return edge; }); @@ -668,7 +660,7 @@ const moved: string[] = []; for (const node of draggedNodes) { // Events and annotations don't affect routing - if (node.type !== 'pathview') continue; + if (!isBlockFlowNode(node)) continue; const snapped = { x: Math.round(node.position.x / GRID_SIZE) * GRID_SIZE, diff --git a/src/lib/components/canvas/flowConverters.ts b/src/lib/components/canvas/flowConverters.ts index 28b529c7..3ec09dea 100644 --- a/src/lib/components/canvas/flowConverters.ts +++ b/src/lib/components/canvas/flowConverters.ts @@ -7,6 +7,14 @@ import type { Connection, Annotation } from '$lib/nodes/types'; import type { EventInstance } from '$lib/events/types'; import { HANDLE_ID } from '$lib/constants/handles'; +/** Canvas node types that are blocks with ports: regular blocks and bus blocks */ +const BLOCK_NODE_TYPES = new Set(['pathview', 'busBlock']); + +/** Whether a canvas node is a block with ports, as opposed to an event or annotation */ +export function isBlockFlowNode(node: Node): boolean { + return BLOCK_NODE_TYPES.has(node.type ?? ''); +} + /** * Convert an EventInstance to a SvelteFlow Node */ @@ -48,7 +56,7 @@ export function toAnnotationNode(annotation: Annotation): Node { /** * Convert a Connection to a SvelteFlow Edge */ -export function toFlowEdge(conn: Connection, carriesBus = false): Edge { +export function toFlowEdge(conn: Connection): Edge { return { id: conn.id, source: conn.sourceNodeId, @@ -56,7 +64,7 @@ export function toFlowEdge(conn: Connection, carriesBus = false): Edge { target: conn.targetNodeId, targetHandle: HANDLE_ID.input(conn.targetNodeId, conn.targetPortIndex), type: 'orthogonal', - data: { waypoints: conn.waypoints, label: conn.label, bus: carriesBus }, + data: { waypoints: conn.waypoints, label: conn.label }, selectable: true, deletable: true, animated: false diff --git a/src/lib/components/canvas/routingSync.ts b/src/lib/components/canvas/routingSync.ts index 0fb676ea..3d71ae6b 100644 --- a/src/lib/components/canvas/routingSync.ts +++ b/src/lib/components/canvas/routingSync.ts @@ -13,6 +13,7 @@ import type { Bounds, PortInfo, PortStub, RouteRequest, SceneNode } from '$lib/r import { getPortInfo } from '$lib/routing'; import { DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '$lib/constants/dimensions'; import { routingStore } from '$lib/stores/routing'; +import { isBlockFlowNode } from './flowConverters'; export interface RoutingSyncSource { /** Block nodes currently shown on the canvas */ @@ -147,7 +148,7 @@ export function createRoutingSync(source: RoutingSyncSource) { const entries: [string, SceneNode][] = []; for (const id of ids) { const node = source.node(id); - if (node?.type === 'pathview') entries.push([id, sceneNodeOf(node, positions?.get(id))]); + if (node && isBlockFlowNode(node)) entries.push([id, sceneNodeOf(node, positions?.get(id))]); } const { changed } = routingStore.diffNodes(entries, false); if (changed.length === 0) return; diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index b1814da5..5dc1c0f3 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -35,7 +35,8 @@ import { screenToFlow } from '$lib/utils/viewUtils'; import { GRID_SIZE, EDGE_SOURCE_OFFSET, EDGE_TARGET_OFFSET, EDGE_CORNER_RADIUS } from '$lib/routing/constants'; import InlineInput from '$lib/components/InlineInput.svelte'; - import { BUS_WIRE } from '$lib/constants/dimensions'; + import { BUS } from '$lib/constants/dimensions'; + import { busWireSignals } from '$lib/stores/busView.svelte'; import type { Direction, RouteResult } from '$lib/routing'; import type { Waypoint } from '$lib/types/nodes'; @@ -299,29 +300,36 @@ return midpoints; }); - // Wires carrying a bus are drawn thicker - const carriesBus = $derived(Boolean((data as { bus?: boolean } | undefined)?.bus)); + // Number of signals on a wire carrying a bus; such wires are drawn thicker and show the count + const busSignals = $derived(busWireSignals.get(id)); // Connection label, shown on the middle of the longest route segment const label = $derived((data as { label?: string } | undefined)?.label ?? ''); const isEditingLabel = $derived(edgeLabelEdit.connectionId === id); - const labelAnchor = $derived.by(() => { - if (!label && !isEditingLabel) return null; + // Middle of the longest route segment, where the label and the bus signal count sit + const segmentAnchor = $derived.by(() => { + if (!label && !isEditingLabel && busSignals === undefined) return null; const points = displayedRoute ? [adjustedSource, ...displayedRoute.path, adjustedTarget] : [adjustedSource, adjustedTarget]; - let anchor = points[0]; + let anchor = { ...points[0], vertical: false }; let longest = -1; for (let i = 0; i < points.length - 1; i++) { - const length = Math.abs(points[i + 1].x - points[i].x) + Math.abs(points[i + 1].y - points[i].y); - if (length > longest) { - longest = length; - anchor = { x: (points[i].x + points[i + 1].x) / 2, y: (points[i].y + points[i + 1].y) / 2 }; + const dx = Math.abs(points[i + 1].x - points[i].x); + const dy = Math.abs(points[i + 1].y - points[i].y); + if (dx + dy > longest) { + longest = dx + dy; + anchor = { + x: (points[i].x + points[i + 1].x) / 2, + y: (points[i].y + points[i + 1].y) / 2, + vertical: dy > dx + }; } } return anchor; }); + const labelAnchor = $derived(label || isEditingLabel ? segmentAnchor : null); function handleEdgeDoubleClick(event: MouseEvent) { event.stopPropagation(); @@ -384,8 +392,8 @@ @@ -421,7 +429,7 @@ - + + + {#if busSignals !== undefined && segmentAnchor} + {busSignals} + {/if} + + {#if label && !isEditingLabel && labelAnchor} {label} import { onDestroy } from 'svelte'; - import { Handle, Position, useUpdateNodeInternals } from '@xyflow/svelte'; + import { useUpdateNodeInternals } from '@xyflow/svelte'; import { nodeRegistry, registryVersion, type NodeInstance } from '$lib/nodes'; import { getShapeCssClass, isSubsystem } from '$lib/nodes/shapes/index'; import { NODE_TYPES } from '$lib/constants/nodeTypes'; @@ -12,15 +12,14 @@ import { iconModeStore } from '$lib/stores/iconMode'; import BlockIcon, { hasBlockIcon } from '$lib/components/icons/BlockIcon.svelte'; import { PREVIEW_GAP, previewSideForRotation } from '$lib/utils/previewBounds'; - import { hoveredHandle, selectedNodeHighlight } from '$lib/stores/hoveredHandle'; - import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte'; + import { selectedNodeHighlight } from '$lib/stores/hoveredHandle'; import { paramInput } from '$lib/actions/paramInput'; import { plotDataStore } from '$lib/plotting/processing/plotDataStore'; - import { getPortPositionCalc, calculateNodeDimensions } from '$lib/constants/dimensions'; - import { truncatePortLabel } from '$lib/utils/portLabels'; + import { calculateNodeDimensions } from '$lib/constants/dimensions'; import { containsMath, renderInlineMath, renderInlineMathSync, measureRenderedMath } from '$lib/utils/inlineMathRenderer'; import { getKatexCssUrl } from '$lib/utils/katexLoader'; import PlotPreview from './PlotPreview.svelte'; + import NodePorts from './NodePorts.svelte'; interface Props { id: string; @@ -97,10 +96,6 @@ const showInputLabels = $derived(nodeShowInputLabels ?? globalShowPortLabels); const showOutputLabels = $derived(nodeShowOutputLabels ?? globalShowPortLabels); - // Actual visibility: setting is ON and ports exist (single source of truth) - const hasVisibleInputLabels = $derived(showInputLabels && data.inputs.length > 0); - const hasVisibleOutputLabels = $derived(showOutputLabels && data.outputs.length > 0); - // Re-measure node when port labels toggle changes $effect(() => { @@ -197,29 +192,6 @@ // Rotation state (0, 1, 2, 3 = 0°, 90°, 180°, 270°) - stored in node params const rotation = $derived((data.params?.['_rotation'] as number) || 0); - // Calculate actual port positions based on rotation - // 0: inputs left, outputs right (default) - // 1: inputs top, outputs bottom - // 2: inputs right, outputs left - // 3: inputs bottom, outputs top - const inputPosition = $derived(() => { - switch (rotation) { - case 1: return Position.Top; - case 2: return Position.Right; - case 3: return Position.Bottom; - default: return Position.Left; - } - }); - - const outputPosition = $derived(() => { - switch (rotation) { - case 1: return Position.Bottom; - case 2: return Position.Left; - case 3: return Position.Top; - default: return Position.Right; - } - }); - // Port is horizontal (left/right) or vertical (top/bottom) const isVertical = $derived(rotation === 1 || rotation === 3); @@ -250,46 +222,6 @@ showIcon )); - /** Inline style for a port label, positioning it outside the block edge - * next to its handle. The handle/wire is always *below* the label from - * the label's perspective — i.e. the anchor point sits at the label's - * bottom-left or bottom-right corner. - * - * Horizontal block: text horizontal, label sits just above the wire stub. - * Vertical block: `writing-mode: sideways-{lr|rl}` rotates the text - * parallel to the wire (no transform tricks needed for positioning, - * so the perpendicular offset works in screen-space directly). Top - * edge reads bottom-to-top, bottom edge top-to-bottom — both read - * *outward* from the block. */ - function portLabelStyle(isInput: boolean, portIndex: number, total: number): string { - const pos = getPortPositionCalc(portIndex, total); - const GAP = 10; // distance from block edge along the wire - const PERP = 5; // perpendicular offset off the wire path - - // Map (rotation, isInput) → which block edge hosts the port. - let edge: 'left' | 'right' | 'top' | 'bottom'; - if (rotation === 0) edge = isInput ? 'left' : 'right'; - else if (rotation === 2) edge = isInput ? 'right' : 'left'; - else if (rotation === 1) edge = isInput ? 'top' : 'bottom'; - else edge = isInput ? 'bottom' : 'top'; - - switch (edge) { - case 'left': - // Anchor (port) at label bottom-right. - return `right: 100%; margin-right: ${GAP}px; top: ${pos}; transform: translateY(calc(-100% - ${PERP}px)); text-align: right;`; - case 'right': - // Anchor at label bottom-left. - return `left: 100%; margin-left: ${GAP}px; top: ${pos}; transform: translateY(calc(-100% - ${PERP}px)); text-align: left;`; - case 'top': - // Reads bottom-to-top, label LEFT of wire. Anchor at bottom-right. - return `bottom: 100%; margin-bottom: ${GAP}px; left: ${pos}; writing-mode: sideways-lr; transform: translateX(calc(-100% - ${PERP}px)); text-align: end;`; - case 'bottom': - // Reads top-to-bottom, label RIGHT of wire. Anchor at top-left - // (= label's bottom-left if you tilt your head left to read). - return `top: 100%; margin-top: ${GAP}px; left: ${pos}; writing-mode: sideways-rl; transform: translateX(${PERP}px); text-align: start;`; - } - } - // Check if this is a Subsystem or Interface node (using shapes utility) const isSubsystemNode = $derived(isSubsystem(data)); const isInterfaceNode = $derived(data.type === NODE_TYPES.INTERFACE); @@ -369,54 +301,6 @@ return String(value); } - // Tooltip position for input handles (show tooltip away from node) - function getInputTooltipPosition(): 'bottom' | 'left' | 'right' | 'top' { - switch (rotation) { - case 1: return 'top'; // inputs on top → tooltip above - case 2: return 'right'; // inputs on right → tooltip to right - case 3: return 'bottom'; // inputs on bottom → tooltip below - default: return 'left'; // inputs on left → tooltip to left - } - } - - // Tooltip position for output handles (show tooltip away from node) - function getOutputTooltipPosition(): 'bottom' | 'left' | 'right' | 'top' { - switch (rotation) { - case 1: return 'bottom'; // outputs on bottom → tooltip below - case 2: return 'left'; // outputs on left → tooltip to left - case 3: return 'top'; // outputs on top → tooltip above - default: return 'right'; // outputs on right → tooltip to right - } - } - - // Handle mouse events for input handles. The hover tooltip is suppressed - // when port labels are already shown — the label IS the name, no point - // also popping a tooltip on top of it. - function handleInputMouseEnter(event: MouseEvent, port: { id: string; name: string }) { - hoveredHandle.set({ nodeId: id, handleId: port.id, color: nodeColor }); - if (!hasVisibleInputLabels) { - showTooltip(port.name, event.currentTarget as HTMLElement, getInputTooltipPosition()); - } - } - - function handleInputMouseLeave(_port: { id: string }) { - hoveredHandle.set(null); - hideTooltip(); - } - - // Handle mouse events for output handles - function handleOutputMouseEnter(event: MouseEvent, port: { id: string; name: string }) { - hoveredHandle.set({ nodeId: id, handleId: port.id, color: nodeColor }); - if (!hasVisibleOutputLabels) { - showTooltip(port.name, event.currentTarget as HTMLElement, getOutputTooltipPosition()); - } - } - - function handleOutputMouseLeave(_port: { id: string }) { - hoveredHandle.set(null); - hideTooltip(); - } - // Highlight connected edges when node is selected $effect(() => { if (selected) { @@ -510,32 +394,6 @@ {/if}
- - {#if hasVisibleInputLabels} - {#each data.inputs as port, i} - - {truncatePortLabel(port.name)} - - {/each} - {/if} - {#if hasVisibleOutputLabels} - {#each data.outputs as port, i} - - {truncatePortLabel(port.name)} - - {/each} - {/if} - {#if allowsDynamicInputs && selected}
@@ -552,35 +410,15 @@
{/if} - - {#key `${rotation}-${data.inputs.length}`} - {#each data.inputs as port, i} - handleInputMouseEnter(e, port)} - onmouseleave={() => handleInputMouseLeave(port)} - /> - {/each} - {/key} - - - {#key `${rotation}-${data.outputs.length}`} - {#each data.outputs as port, i} - handleOutputMouseEnter(e, port)} - onmouseleave={() => handleOutputMouseLeave(port)} - /> - {/each} - {/key} +
diff --git a/src/lib/components/nodes/BusBlockNode.svelte b/src/lib/components/nodes/BusBlockNode.svelte new file mode 100644 index 00000000..9debff98 --- /dev/null +++ b/src/lib/components/nodes/BusBlockNode.svelte @@ -0,0 +1,142 @@ + + + +
{ + e.stopPropagation(); + openNodeDialog(id); + }} + onmouseenter={handleMouseEnter} + onmouseleave={hideTooltip} +> + + + + + + + + +
+ + diff --git a/src/lib/components/nodes/NodePorts.svelte b/src/lib/components/nodes/NodePorts.svelte new file mode 100644 index 00000000..a5befb71 --- /dev/null +++ b/src/lib/components/nodes/NodePorts.svelte @@ -0,0 +1,226 @@ + + + +{#if hasVisibleInputLabels} + {#each inputs as port, i} + + {truncatePortLabel(inputName(port, i))} + + {/each} +{/if} +{#if hasVisibleOutputLabels} + {#each outputs as port, i} + + {truncatePortLabel(port.name)} + + {/each} +{/if} + + +{#key `${rotation}-${inputs.length}`} + {#each inputs as port, i} + handleInputMouseEnter(e, inputName(port, i), port.id)} + onmouseleave={handleMouseLeave} + /> + {/each} +{/key} + + +{#key `${rotation}-${outputs.length}`} + {#each outputs as port, i} + handleOutputMouseEnter(e, port.name, port.id)} + onmouseleave={handleMouseLeave} + /> + {/each} +{/key} + + diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index 5bce3620..9fcb6a4f 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -50,12 +50,31 @@ export const INLINE_INPUT = { maxSuggestions: 8 } as const; -/** Wire carrying a bus */ -export const BUS_WIRE = { - /** Line width in pixels */ - strokeWidth: 3 +/** Bus Creator and Bus Selector blocks, and the wires carrying buses */ +export const BUS = { + /** Block width across the wedge: 2 grid units */ + blockWidth: G.x2, + /** Length of the narrow wedge side where the bus attaches: 2 grid units */ + narrowSide: G.x2, + /** Line width of a wire carrying a bus in pixels */ + wireWidth: 4, + /** Distance of the signal count from the wire in pixels */ + countOffset: 8, + /** Arrowhead scale on wires carrying a bus */ + arrowScale: 1.4 } as const; +/** + * Size of a Bus Creator or Bus Selector: one port spacing per port along the + * wedge, at least two, and a fixed width across it. Grid-aligned like blocks. + */ +export function busBlockDimensions(inputCount: number, outputCount: number, rotation: number): { width: number; height: number } { + const length = Math.max(2, inputCount, outputCount) * NODE.portSpacing; + return rotation === 1 || rotation === 3 + ? { width: length, height: BUS.blockWidth } + : { width: BUS.blockWidth, height: length }; +} + /** Event node dimensions (grid-aligned) */ export const EVENT = { /** Total bounding box size: 8 grid units = 80px */ diff --git a/src/lib/stores/busView.svelte.ts b/src/lib/stores/busView.svelte.ts new file mode 100644 index 00000000..1d2655c1 --- /dev/null +++ b/src/lib/stores/busView.svelte.ts @@ -0,0 +1,60 @@ +/** + * Bus view - bus facts of the current graph level, for rendering + * + * Computed centrally whenever the canvas rebuilds its edges, so a wire or bus + * block only re-renders when its own entry changes. Bus structure follows wires + * across all levels, so the whole model is analyzed. + */ + +import { SvelteMap } from 'svelte/reactivity'; +import type { Connection, NodeInstance } from '$lib/nodes/types'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; +import { analyzeBuses, containsBusBlocks, signalLeaves } from '$lib/bus/expand'; + +/** Connection ID to the number of signals the wire carries; absent for plain wires */ +export const busWireSignals = new SvelteMap(); + +/** Bus Creator ID to the signal name of each of its inputs */ +export const busCreatorSignals = new SvelteMap(); + +function sync(target: SvelteMap, next: Map, same: (a: T, b: T) => boolean): void { + for (const key of [...target.keys()]) { + if (!next.has(key)) target.delete(key); + } + for (const [key, value] of next) { + const current = target.get(key); + if (current === undefined || !same(current, value)) target.set(key, value); + } +} + +const sameNames = (a: string[], b: string[]) => a.length === b.length && a.every((name, i) => name === b[i]); + +/** + * Recompute the bus view for the connections of the graph level at `path` + * @param model - Root nodes and connections of the whole model + */ +export function updateBusView( + model: { nodes: NodeInstance[]; connections: Connection[] }, + path: string[], + connections: Connection[] +): void { + const wires = new Map(); + const creators = new Map(); + + if (containsBusBlocks(model.nodes)) { + const analysis = analyzeBuses(model.nodes, model.connections); + const level = analysis.levelAt(path); + if (level) { + for (const connection of connections) { + const structure = analysis.structureOut(level, connection.sourceNodeId, connection.sourcePortIndex); + if (structure) wires.set(connection.id, signalLeaves(structure).length); + } + for (const node of level.nodeList) { + if (node.type === NODE_TYPES.BUS_CREATOR) creators.set(node.id, analysis.elementNames(level, node)); + } + } + } + + sync(busWireSignals, wires, (a, b) => a === b); + sync(busCreatorSignals, creators, sameNames); +} From fa3d52c7ebdb0f2e4e2d26293ed517901d0d9d47 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 18:06:11 +0200 Subject: [PATCH 06/17] Add port controls to bus creator, solid bus ports and a wider bus arrow --- .../components/edges/OrthogonalEdge.svelte | 10 +- src/lib/components/nodes/BaseNode.svelte | 116 +------------ src/lib/components/nodes/BusBlockNode.svelte | 6 + src/lib/components/nodes/NodePorts.svelte | 161 +++++++++++++++++- src/lib/constants/dimensions.ts | 4 +- src/lib/stores/busView.svelte.ts | 9 + 6 files changed, 189 insertions(+), 117 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 5dc1c0f3..c41a89e7 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -63,6 +63,12 @@ [Position.Bottom]: -90 }; + /** Arrowhead with its tip at the origin, pointing along +x */ + const ARROW_PATH = 'M -5 -2.5 L -1 -0.5 Q 0 0 -1 0.5 L -5 2.5 Q -6 3 -6 2 L -6 -2 Q -6 -3 -5 -2.5 Z'; + + /** Wider arrowhead for the thicker bus wire; its base overlaps the wire end */ + const BUS_ARROW_PATH = 'M -8 -5 L -1 -0.8 Q 0 0 -1 0.8 L -8 5 Q -9 5.5 -9 4.5 L -9 -4.5 Q -9 -5.5 -8 -5 Z'; + /** Minimum distance of a segment midpoint handle from an existing waypoint */ const MIN_DISTANCE_FROM_WAYPOINT = 20; @@ -429,9 +435,9 @@ - + graphStore.addInputPort(id)); - } - // Get min ports from type definition const minInputs = $derived(typeDef?.ports.minInputs ?? 1); const minOutputs = $derived(typeDef?.ports.minOutputs ?? 1); - // Remove input port (respects minInputs) - function handleRemoveInput(event: MouseEvent) { - event.stopPropagation(); - if (data.inputs.length > minInputs) { - historyStore.mutate(() => graphStore.removeInputPort(id)); - } - } - - // Add output port - function handleAddOutput(event: MouseEvent) { - event.stopPropagation(); - historyStore.mutate(() => graphStore.addOutputPort(id)); - } - - // Remove output port (respects minOutputs) - function handleRemoveOutput(event: MouseEvent) { - event.stopPropagation(); - if (data.outputs.length > minOutputs) { - historyStore.mutate(() => graphStore.removeOutputPort(id)); - } - } - // Get shape class from unified shapes utility const shapeClass = $derived(() => typeDef ? getShapeCssClass(typeDef) : 'shape-default'); @@ -394,30 +367,22 @@ {/if} - - {#if allowsDynamicInputs && selected} -
- - -
- {/if} - - - {#if allowsDynamicOutputs && selected && !syncPorts} -
- - -
- {/if} - + @@ -665,69 +630,6 @@ color: var(--text-muted); } - /* Port controls (+/- buttons) */ - .port-controls { - position: absolute; - display: flex; - gap: 2px; - z-index: 10; - } - - .port-controls-left { - left: -24px; - top: 50%; - transform: translateY(-50%); - flex-direction: column; - } - - .port-controls-right { - right: -24px; - top: 50%; - transform: translateY(-50%); - flex-direction: column; - } - - .port-controls-top { - top: -24px; - left: 50%; - transform: translateX(-50%); - flex-direction: row; - } - - .port-controls-bottom { - bottom: -24px; - left: 50%; - transform: translateX(-50%); - flex-direction: row; - } - - .port-btn { - width: 16px; - height: 16px; - padding: 0; - border: 1px solid var(--node-color); - border-radius: var(--radius-sm); - background: var(--surface-raised); - color: var(--node-color); - font-size: 12px; - font-weight: 600; - line-height: 1; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - } - - .port-btn:hover:not(:disabled) { - background: var(--node-color); - color: var(--surface-raised); - } - - .port-btn:disabled { - opacity: 0.3; - cursor: not-allowed; - } - /* Handles - Hollow arrow/pentagon shape with rounded corners */ :global(.node .svelte-flow__handle) { width: 10px; diff --git a/src/lib/components/nodes/BusBlockNode.svelte b/src/lib/components/nodes/BusBlockNode.svelte index 9debff98..17289b11 100644 --- a/src/lib/components/nodes/BusBlockNode.svelte +++ b/src/lib/components/nodes/BusBlockNode.svelte @@ -93,15 +93,21 @@
+ diff --git a/src/lib/components/nodes/NodePorts.svelte b/src/lib/components/nodes/NodePorts.svelte index a5befb71..d2f4d6d4 100644 --- a/src/lib/components/nodes/NodePorts.svelte +++ b/src/lib/components/nodes/NodePorts.svelte @@ -1,14 +1,17 @@ +{#if dynamicInputs && selected} +
+ + +
+{/if} + + +{#if dynamicOutputs && selected} +
+ + +
+{/if} + {#key `${rotation}-${inputs.length}`} {#each inputs as port, i} @@ -174,7 +229,7 @@ position={inputPosition} id={port.id} style={isVertical ? `left: ${getPortPositionCalc(i, inputs.length)};` : `top: ${getPortPositionCalc(i, inputs.length)};`} - class="handle handle-input" + class={handleClass('input', i)} onmouseenter={(e) => handleInputMouseEnter(e, inputName(port, i), port.id)} onmouseleave={handleMouseLeave} /> @@ -189,7 +244,7 @@ position={outputPosition} id={port.id} style={isVertical ? `left: ${getPortPositionCalc(i, outputs.length)};` : `top: ${getPortPositionCalc(i, outputs.length)};`} - class="handle handle-output" + class={handleClass('output', i)} onmouseenter={(e) => handleOutputMouseEnter(e, port.name, port.id)} onmouseleave={handleMouseLeave} /> @@ -223,4 +278,100 @@ color: var(--node-color, var(--accent)); font-weight: 500; } + + /* Port controls (+/- buttons) */ + .port-controls { + position: absolute; + display: flex; + gap: 2px; + z-index: 10; + } + + .port-controls-left { + left: -24px; + top: 50%; + transform: translateY(-50%); + flex-direction: column; + } + + .port-controls-right { + right: -24px; + top: 50%; + transform: translateY(-50%); + flex-direction: column; + } + + .port-controls-top { + top: -24px; + left: 50%; + transform: translateX(-50%); + flex-direction: row; + } + + .port-controls-bottom { + bottom: -24px; + left: 50%; + transform: translateX(-50%); + flex-direction: row; + } + + .port-btn { + width: 16px; + height: 16px; + padding: 0; + border: 1px solid var(--node-color); + border-radius: var(--radius-sm); + background: var(--surface-raised); + color: var(--node-color); + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + + .port-btn:hover:not(:disabled) { + background: var(--node-color); + color: var(--surface-raised); + } + + .port-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + /* Ports carrying a bus: a solid arrow, wider across the wire, matching the + * thicker bus wire. Same length along the wire as a normal handle, so wires + * and routing attach at the same point. */ + :global(.node .svelte-flow__handle.handle-bus) { + height: 12px; + } + + :global(.node[data-rotation="1"] .svelte-flow__handle.handle-bus), + :global(.node[data-rotation="3"] .svelte-flow__handle.handle-bus) { + width: 12px; + height: 10px; + } + + :global(.node .svelte-flow__handle.handle-bus::after) { + display: none; + } + + :global(.node[data-rotation="0"] .svelte-flow__handle.handle-bus::before) { + clip-path: path('M 1 0 L 5 0 Q 6 0 6.7 0.7 L 9.3 5.3 Q 10 6 9.3 6.7 L 6.7 11.3 Q 6 12 5 12 L 1 12 Q 0 12 0 11 L 0 1 Q 0 0 1 0 Z'); + } + + :global(.node[data-rotation="1"] .svelte-flow__handle.handle-bus::before) { + clip-path: path('M 0 1 L 0 5 Q 0 6 0.7 6.7 L 5.3 9.3 Q 6 10 6.7 9.3 L 11.3 6.7 Q 12 6 12 5 L 12 1 Q 12 0 11 0 L 1 0 Q 0 0 0 1 Z'); + } + + :global(.node[data-rotation="2"] .svelte-flow__handle.handle-bus::before) { + clip-path: path('M 9 0 L 5 0 Q 4 0 3.3 0.7 L 0.7 5.3 Q 0 6 0.7 6.7 L 3.3 11.3 Q 4 12 5 12 L 9 12 Q 10 12 10 11 L 10 1 Q 10 0 9 0 Z'); + } + + :global(.node[data-rotation="3"] .svelte-flow__handle.handle-bus::before) { + clip-path: path('M 0 9 L 0 5 Q 0 4 0.7 3.3 L 5.3 0.7 Q 6 0 6.7 0.7 L 11.3 3.3 Q 12 4 12 5 L 12 9 Q 12 10 11 10 L 1 10 Q 0 10 0 9 Z'); + } diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index 9fcb6a4f..e0eaeda1 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -59,9 +59,7 @@ export const BUS = { /** Line width of a wire carrying a bus in pixels */ wireWidth: 4, /** Distance of the signal count from the wire in pixels */ - countOffset: 8, - /** Arrowhead scale on wires carrying a bus */ - arrowScale: 1.4 + countOffset: 8 } as const; /** diff --git a/src/lib/stores/busView.svelte.ts b/src/lib/stores/busView.svelte.ts index 1d2655c1..c997f465 100644 --- a/src/lib/stores/busView.svelte.ts +++ b/src/lib/stores/busView.svelte.ts @@ -17,6 +17,9 @@ export const busWireSignals = new SvelteMap(); /** Bus Creator ID to the signal name of each of its inputs */ export const busCreatorSignals = new SvelteMap(); +/** Node ID to the indices of its ports that carry a bus; absent when none does */ +export const busPorts = new SvelteMap(); + function sync(target: SvelteMap, next: Map, same: (a: T, b: T) => boolean): void { for (const key of [...target.keys()]) { if (!next.has(key)) target.delete(key); @@ -28,6 +31,7 @@ function sync(target: SvelteMap, next: Map, same: (a: T } const sameNames = (a: string[], b: string[]) => a.length === b.length && a.every((name, i) => name === b[i]); +const sameIndices = (a: number[], b: number[]) => a.length === b.length && a.every((index, i) => index === b[i]); /** * Recompute the bus view for the connections of the graph level at `path` @@ -40,6 +44,7 @@ export function updateBusView( ): void { const wires = new Map(); const creators = new Map(); + const ports = new Map(); if (containsBusBlocks(model.nodes)) { const analysis = analyzeBuses(model.nodes, model.connections); @@ -51,10 +56,14 @@ export function updateBusView( } for (const node of level.nodeList) { if (node.type === NODE_TYPES.BUS_CREATOR) creators.set(node.id, analysis.elementNames(level, node)); + const inputs = node.inputs.flatMap((_, i) => (analysis.structureIn(level, node.id, i) ? [i] : [])); + const outputs = node.outputs.flatMap((_, i) => (analysis.structureOut(level, node.id, i) ? [i] : [])); + if (inputs.length > 0 || outputs.length > 0) ports.set(node.id, { inputs, outputs }); } } } sync(busWireSignals, wires, (a, b) => a === b); sync(busCreatorSignals, creators, sameNames); + sync(busPorts, ports, (a, b) => sameIndices(a.inputs, b.inputs) && sameIndices(a.outputs, b.outputs)); } From 85d6d4e8ecad1a0a4cf43657fced81a94ba83c66 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 18:10:59 +0200 Subject: [PATCH 07/17] Start bus wires inside their source port so they join flush --- src/lib/components/edges/OrthogonalEdge.svelte | 10 +++++++--- src/lib/constants/dimensions.ts | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index c41a89e7..26236cdb 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -198,7 +198,13 @@ } // Path ends at the handle tips: small inset at the source, room for the arrowhead at the target - const adjustedSource = $derived(alongFacing(sourceX, sourceY, sourcePosition, -EDGE_SOURCE_OFFSET)); + // Number of signals on a wire carrying a bus; such wires are drawn thicker and show the count + const busSignals = $derived(busWireSignals.get(id)); + + // A bus wire starts inside the solid bus port, so the thick line joins it without a gap + const adjustedSource = $derived( + alongFacing(sourceX, sourceY, sourcePosition, -(busSignals !== undefined ? BUS.sourceInset : EDGE_SOURCE_OFFSET)) + ); const adjustedTarget = $derived(alongFacing(targetX, targetY, targetPosition, EDGE_TARGET_OFFSET)); /** @@ -306,8 +312,6 @@ return midpoints; }); - // Number of signals on a wire carrying a bus; such wires are drawn thicker and show the count - const busSignals = $derived(busWireSignals.get(id)); // Connection label, shown on the middle of the longest route segment const label = $derived((data as { label?: string } | undefined)?.label ?? ''); diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index e0eaeda1..1ded5b27 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -58,6 +58,8 @@ export const BUS = { narrowSide: G.x2, /** Line width of a wire carrying a bus in pixels */ wireWidth: 4, + /** A bus wire starts this far inside its source port, where the solid port is wider than the wire */ + sourceInset: 4, /** Distance of the signal count from the wire in pixels */ countOffset: 8 } as const; From e071ece2d93c82d8d47315d354d5bb03934af9ea Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 18:18:33 +0200 Subject: [PATCH 08/17] Edit bus signal names inline, keep bus wires 2.5 times as thick in every state --- src/lib/components/FlowCanvas.svelte | 8 +-- src/lib/components/InlineInput.svelte | 5 +- src/lib/components/contextMenuBuilders.ts | 4 +- .../components/edges/OrthogonalEdge.svelte | 26 ++++---- src/lib/components/nodes/BusBlockNode.svelte | 59 ++++++++++++++++- src/lib/components/nodes/NodePorts.svelte | 63 +++++++++++++++++-- src/lib/constants/dimensions.ts | 4 +- src/lib/stores/busView.svelte.ts | 21 ++++++- src/lib/stores/edgeLabelEdit.svelte.ts | 10 --- src/lib/stores/graph/buses.ts | 35 ++++++++++- src/lib/stores/graph/index.ts | 1 + src/lib/stores/inlineEdit.svelte.ts | 12 ++++ 12 files changed, 206 insertions(+), 42 deletions(-) delete mode 100644 src/lib/stores/edgeLabelEdit.svelte.ts create mode 100644 src/lib/stores/inlineEdit.svelte.ts diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index b8afa3c7..bf6f5dd1 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -1051,10 +1051,10 @@ fill: var(--grid-dot); } - /* Edge styling */ + /* Edge styling; --wire-scale thickens a wire in every state, e.g. for buses */ :global(.svelte-flow__edge-path) { stroke: var(--edge); - stroke-width: 1; + stroke-width: calc(1px * var(--wire-scale, 1)); transition: stroke 0.15s ease; cursor: pointer; } @@ -1068,12 +1068,12 @@ :global(.svelte-flow__edge:hover .svelte-flow__edge-path) { stroke: var(--accent, #0070C0); - stroke-width: 1; + stroke-width: calc(1px * var(--wire-scale, 1)); } :global(.svelte-flow__edge.selected .svelte-flow__edge-path) { stroke: var(--accent, #0070C0); - stroke-width: 1.5; + stroke-width: calc(1.5px * var(--wire-scale, 1)); } /* Connection line */ diff --git a/src/lib/components/InlineInput.svelte b/src/lib/components/InlineInput.svelte index eb34c30b..4844c9a3 100644 --- a/src/lib/components/InlineInput.svelte +++ b/src/lib/components/InlineInput.svelte @@ -24,10 +24,12 @@ // svelte-ignore state_referenced_locally let text = $state(value); let activeIndex = $state(0); + // Until the user types, the initial text does not filter and all suggestions are offered + let touched = $state(false); let listPosition = $state<{ x: number; y: number } | null>(null); let listEl = $state(null); - const query = $derived(text.trim()); + const query = $derived(touched ? text.trim() : ''); // Matching suggestions, prefix matches first, then the create entry const options = $derived.by((): Option[] => { @@ -81,6 +83,7 @@ } }; const onInput = () => { + touched = true; activeIndex = 0; }; const onPointerDown = (event: PointerEvent) => { diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index e1be1ef6..656797b2 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -9,7 +9,7 @@ import type { ContextMenuTarget } from '$lib/stores/contextMenu'; import { graphStore, ANNOTATION_FONT_SIZE } from '$lib/stores/graph'; import { historyStore } from '$lib/stores/history'; import { routingStore } from '$lib/stores/routing'; -import { editEdgeLabel } from '$lib/stores/edgeLabelEdit.svelte'; +import { editInline } from '$lib/stores/inlineEdit.svelte'; import { eventStore } from '$lib/stores/events'; import { clipboardStore } from '$lib/stores/clipboard'; import { codePreviewStore } from '$lib/stores/codePreview'; @@ -404,7 +404,7 @@ function buildEdgeMenu(edgeId: string): MenuItemType[] { { label: 'Edit Label', icon: 'tag', - action: () => editEdgeLabel(edgeId) + action: () => editInline(edgeId) }, { label: 'Reset Route', diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 26236cdb..8e275327 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -30,13 +30,13 @@ import { routingStore } from '$lib/stores/routing'; import { graphStore } from '$lib/stores/graph'; import { edgeHighlights } from '$lib/stores/edgeHighlight'; - import { edgeLabelEdit, editEdgeLabel } from '$lib/stores/edgeLabelEdit.svelte'; + import { inlineEdit, editInline } from '$lib/stores/inlineEdit.svelte'; import { historyStore } from '$lib/stores/history'; import { screenToFlow } from '$lib/utils/viewUtils'; import { GRID_SIZE, EDGE_SOURCE_OFFSET, EDGE_TARGET_OFFSET, EDGE_CORNER_RADIUS } from '$lib/routing/constants'; import InlineInput from '$lib/components/InlineInput.svelte'; import { BUS } from '$lib/constants/dimensions'; - import { busWireSignals } from '$lib/stores/busView.svelte'; + import { busWireSignals, busCreatorWires } from '$lib/stores/busView.svelte'; import type { Direction, RouteResult } from '$lib/routing'; import type { Waypoint } from '$lib/types/nodes'; @@ -67,7 +67,7 @@ const ARROW_PATH = 'M -5 -2.5 L -1 -0.5 Q 0 0 -1 0.5 L -5 2.5 Q -6 3 -6 2 L -6 -2 Q -6 -3 -5 -2.5 Z'; /** Wider arrowhead for the thicker bus wire; its base overlaps the wire end */ - const BUS_ARROW_PATH = 'M -8 -5 L -1 -0.8 Q 0 0 -1 0.8 L -8 5 Q -9 5.5 -9 4.5 L -9 -4.5 Q -9 -5.5 -8 -5 Z'; + const BUS_ARROW_PATH = 'M -6 -3.75 L -1 -0.6 Q 0 0 -1 0.6 L -6 3.75 Q -7 4.2 -7 3.2 L -7 -3.2 Q -7 -4.2 -6 -3.75 Z'; /** Minimum distance of a segment midpoint handle from an existing waypoint */ const MIN_DISTANCE_FROM_WAYPOINT = 20; @@ -315,7 +315,7 @@ // Connection label, shown on the middle of the longest route segment const label = $derived((data as { label?: string } | undefined)?.label ?? ''); - const isEditingLabel = $derived(edgeLabelEdit.connectionId === id); + const isEditingLabel = $derived(inlineEdit.targetId === id); // Middle of the longest route segment, where the label and the bus signal count sit const segmentAnchor = $derived.by(() => { @@ -343,12 +343,12 @@ function handleEdgeDoubleClick(event: MouseEvent) { event.stopPropagation(); - editEdgeLabel(id); + editInline(id); } function commitLabel(text: string) { - if (edgeLabelEdit.connectionId !== id) return; - editEdgeLabel(null); + if (inlineEdit.targetId !== id) return; + editInline(null); if (text.trim() === label) return; historyStore.mutate(() => graphStore.updateConnectionLabel(id, text)); } @@ -403,7 +403,7 @@ @@ -460,8 +460,8 @@ > {/if} - - {#if label && !isEditingLabel && labelAnchor} + + {#if label && !isEditingLabel && labelAnchor && !busCreatorWires.has(id)} - editEdgeLabel(null)} /> + editInline(null)} /> {/if} @@ -501,10 +501,6 @@ fill: var(--accent); } - .bus-wire :global(.svelte-flow__edge-path) { - stroke-width: var(--bus-wire-width); - } - /* Highlight the edge path when handle is hovered */ .highlighted :global(.svelte-flow__edge-path) { stroke: var(--highlight-color, var(--accent)) !important; diff --git a/src/lib/components/nodes/BusBlockNode.svelte b/src/lib/components/nodes/BusBlockNode.svelte index 17289b11..1ca49c12 100644 --- a/src/lib/components/nodes/BusBlockNode.svelte +++ b/src/lib/components/nodes/BusBlockNode.svelte @@ -1,6 +1,13 @@ @@ -153,7 +159,7 @@ busOutputs={isCreator ? [0] : undefined} signalLabels {editingLabel} - onLabelEdit={(direction, index) => editInline(`${id}:${direction}:${index}`)} + onLabelEdit={startLabelEdit} {labelEditor} /> From 59bafe4780b80d2af1441f59d0f2abc8b64dcd0b Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 18:29:11 +0200 Subject: [PATCH 10/17] Remove the signal count from bus wires --- .../components/edges/OrthogonalEdge.svelte | 46 ++----------------- src/lib/constants/dimensions.ts | 4 +- 2 files changed, 4 insertions(+), 46 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 8e275327..c2410b49 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -198,7 +198,7 @@ } // Path ends at the handle tips: small inset at the source, room for the arrowhead at the target - // Number of signals on a wire carrying a bus; such wires are drawn thicker and show the count + // Number of signals on a wire carrying a bus; such wires are drawn thicker const busSignals = $derived(busWireSignals.get(id)); // A bus wire starts inside the solid bus port, so the thick line joins it without a gap @@ -317,9 +317,9 @@ const label = $derived((data as { label?: string } | undefined)?.label ?? ''); const isEditingLabel = $derived(inlineEdit.targetId === id); - // Middle of the longest route segment, where the label and the bus signal count sit + // Middle of the longest route segment, where the label sits const segmentAnchor = $derived.by(() => { - if (!label && !isEditingLabel && busSignals === undefined) return null; + if (!label && !isEditingLabel) return null; const points = displayedRoute ? [adjustedSource, ...displayedRoute.path, adjustedTarget] : [adjustedSource, adjustedTarget]; @@ -448,18 +448,6 @@ /> - - {#if busSignals !== undefined && segmentAnchor} - {busSignals} - {/if} - {#if label && !isEditingLabel && labelAnchor && !busCreatorWires.has(id)} Date: Mon, 14 Sep 2026 18:35:53 +0200 Subject: [PATCH 11/17] Keep bus block angles fixed, hollow bus ports, port label toggles and signal renames with confirmation --- src/lib/bus/expand.ts | 17 +++- src/lib/bus/rename.test.ts | 41 +++++++++ src/lib/bus/rename.ts | 90 ++++++++++++++++++++ src/lib/components/nodes/BusBlockNode.svelte | 56 +++++++++--- src/lib/components/nodes/NodePorts.svelte | 26 +++++- src/lib/constants/dimensions.ts | 8 +- src/lib/stores/graph/buses.ts | 43 +++++++++- src/lib/stores/graph/index.ts | 1 + src/lib/utils/svgPath.test.ts | 16 ++++ src/lib/utils/svgPath.ts | 26 ++++++ 10 files changed, 302 insertions(+), 22 deletions(-) create mode 100644 src/lib/bus/rename.test.ts create mode 100644 src/lib/bus/rename.ts create mode 100644 src/lib/utils/svgPath.test.ts create mode 100644 src/lib/utils/svgPath.ts diff --git a/src/lib/bus/expand.ts b/src/lib/bus/expand.ts index fef1f44d..77b0450d 100644 --- a/src/lib/bus/expand.ts +++ b/src/lib/bus/expand.ts @@ -21,6 +21,8 @@ export type BusStructure = BusElement[] | null; export interface BusElement { name: string; structure: BusStructure; + /** Bus Creator input the element was bundled at, to follow renames */ + origin?: { creatorId: string; input: number }; } /** One graph level: the root graph or the graph inside a subsystem */ @@ -39,7 +41,14 @@ export interface BusLevel { type Endpoint = { nodeId: string; port: number } | null; -const SEPARATOR = '.'; +/** Separator of the names in a signal path such as "inner.b" */ +export const SIGNAL_SEPARATOR = '.'; +const SEPARATOR = SIGNAL_SEPARATOR; + +/** Subsystem IDs from the root down to a level */ +export function levelPath(level: BusLevel): string[] { + return level.parent ? [...levelPath(level.parent.level), level.parent.subsystem.id] : []; +} export function isBusBlock(node: NodeInstance): boolean { return node.type === NODE_TYPES.BUS_CREATOR || node.type === NODE_TYPES.BUS_SELECTOR; @@ -166,7 +175,11 @@ export function analyzeBuses(nodes: NodeInstance[], connections: Connection[]) { if (!node) return null; switch (node.type) { case NODE_TYPES.BUS_CREATOR: - return elementNames(level, node).map((name, i) => ({ name, structure: structureIn(level, node.id, i) })); + return elementNames(level, node).map((name, i) => ({ + name, + structure: structureIn(level, node.id, i), + origin: { creatorId: node.id, input: i } + })); case NODE_TYPES.BUS_SELECTOR: { const path = selectedSignals(node)[port]; return path ? (elementAt(structureIn(level, node.id, 0), path)?.structure ?? null) : null; diff --git a/src/lib/bus/rename.test.ts b/src/lib/bus/rename.test.ts new file mode 100644 index 00000000..0a9e844a --- /dev/null +++ b/src/lib/bus/rename.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import type { Connection, NodeInstance } from '$lib/types/nodes'; +import fixtures from '../../../tests/fixtures/bus_expansion.json'; +import { renamedSignals, selectorUses } from './rename'; + +function load(name: string) { + const scenario = fixtures.scenarios.find((s) => s.name === name)!; + return { + nodes: scenario.nodes as unknown as NodeInstance[], + connections: scenario.connections as unknown as Connection[] + }; +} + +const relabel = (connections: Connection[], id: string, label: string) => + connections.map((c) => (c.id === id ? { ...c, label } : c)); + +describe('bus signal renames', () => { + it('finds a selector inside a subsystem and follows the rename', () => { + const { nodes, connections } = load('bus into a subsystem'); + const source = { path: [], creatorId: 'C', input: 1 }; + const uses = selectorUses(nodes, connections, source); + expect(uses).toEqual([{ path: ['Sub'], selectorId: 'Sel', output: 0, signal: 'b', segment: 0 }]); + expect(renamedSignals(nodes, relabel(connections, 'c2', 'beta'), source, uses).map((u) => u.signal)).toEqual(['beta']); + }); + + it('renames a name inside a nested signal path', () => { + const { nodes, connections } = load('nested buses and selecting a sub-bus'); + const source = { path: [], creatorId: 'C1', input: 1 }; + const uses = selectorUses(nodes, connections, source); + expect(uses.map((u) => `${u.selectorId}:${u.output}:${u.signal}`)).toEqual(['S:0:inner.b']); + expect(renamedSignals(nodes, relabel(connections, 'c2', 'beta'), source, uses).map((u) => u.signal)).toEqual([ + 'inner.beta' + ]); + }); + + it('ignores signals bundled at other inputs', () => { + const { nodes, connections } = load('flat creator and selector'); + expect(selectorUses(nodes, connections, { path: [], creatorId: 'C', input: 1 }).map((u) => u.output)).toEqual([0]); + expect(selectorUses(nodes, connections, { path: [], creatorId: 'other', input: 0 })).toEqual([]); + }); +}); diff --git a/src/lib/bus/rename.ts b/src/lib/bus/rename.ts new file mode 100644 index 00000000..ce9d5270 --- /dev/null +++ b/src/lib/bus/rename.ts @@ -0,0 +1,90 @@ +/** + * Bus signal renames - Bus Selector outputs that pick a signal named at a Bus + * Creator input, found through nested buses and subsystems, and their picked + * signal paths after the rename + */ + +import type { Connection, NodeInstance } from '$lib/types/nodes'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; +import { + analyzeBuses, + levelPath, + selectedSignals, + SIGNAL_SEPARATOR, + type BusLevel, + type BusStructure +} from './expand'; + +/** A Bus Creator input, located by the subsystem path of its level */ +export interface CreatorInput { + path: string[]; + creatorId: string; + input: number; +} + +/** A Bus Selector output picking a signal that runs through a creator input */ +export interface SelectorUse { + path: string[]; + selectorId: string; + output: number; + signal: string; + /** Index of the name in the signal path that the creator input names */ + segment: number; +} + +function* levels(level: BusLevel): Generator { + yield level; + for (const child of level.children.values()) yield* levels(child); +} + +/** Index of the path name bundled at the creator input, or -1 if the path does not run through it */ +function segmentThrough(structure: BusStructure, signal: string, source: CreatorInput): number { + let elements = structure; + const names = signal.split(SIGNAL_SEPARATOR); + for (let i = 0; i < names.length; i++) { + const element = elements?.find((e) => e.name === names[i]); + if (!element) return -1; + if (element.origin?.creatorId === source.creatorId && element.origin.input === source.input) return i; + elements = element.structure; + } + return -1; +} + +/** Selector outputs anywhere in the model whose picked signal runs through the creator input */ +export function selectorUses(nodes: NodeInstance[], connections: Connection[], source: CreatorInput): SelectorUse[] { + const analysis = analyzeBuses(nodes, connections); + const uses: SelectorUse[] = []; + for (const level of levels(analysis.root)) { + for (const node of level.nodeList) { + if (node.type !== NODE_TYPES.BUS_SELECTOR) continue; + const structure = analysis.structureIn(level, node.id, 0); + selectedSignals(node).forEach((signal, output) => { + const segment = segmentThrough(structure, signal, source); + if (segment >= 0) uses.push({ path: levelPath(level), selectorId: node.id, output, signal, segment }); + }); + } + } + return uses; +} + +/** + * The uses with their signal paths following the renamed creator input. + * `nodes` and `connections` are the model after the rename. + */ +export function renamedSignals( + nodes: NodeInstance[], + connections: Connection[], + source: CreatorInput, + uses: SelectorUse[] +): SelectorUse[] { + const analysis = analyzeBuses(nodes, connections); + const level = analysis.levelAt(source.path); + const creator = level?.nodes.get(source.creatorId); + if (!level || !creator) return []; + const name = analysis.elementNames(level, creator)[source.input]; + return uses.map((use) => { + const names = use.signal.split(SIGNAL_SEPARATOR); + names[use.segment] = name; + return { ...use, signal: names.join(SIGNAL_SEPARATOR) }; + }); +} diff --git a/src/lib/components/nodes/BusBlockNode.svelte b/src/lib/components/nodes/BusBlockNode.svelte index 61264433..dd3bf529 100644 --- a/src/lib/components/nodes/BusBlockNode.svelte +++ b/src/lib/components/nodes/BusBlockNode.svelte @@ -8,6 +8,10 @@ import { inlineEdit, editInline } from '$lib/stores/inlineEdit.svelte'; import { busSelectorOptions } from '$lib/stores/busView.svelte'; import { selectedSignals } from '$lib/bus/expand'; + import { renamedSignals, selectorUses } from '$lib/bus/rename'; + import { confirmationStore } from '$lib/stores/confirmation'; + import { portLabelsStore } from '$lib/stores/portLabels'; + import { roundedPolygonPath } from '$lib/utils/svgPath'; import { NODE_TYPES } from '$lib/constants/nodeTypes'; import { BUS, busBlockDimensions } from '$lib/constants/dimensions'; import { openNodeDialog } from '$lib/stores/nodeDialog'; @@ -36,17 +40,22 @@ const size = $derived(busBlockDimensions(data.inputs.length, data.outputs.length, rotation)); const nodeColor = $derived(data.color || 'var(--accent)'); - // Wedge in the unrotated frame, wide side left for a creator and right for a selector + // Trapezoid in the unrotated frame, wide side left for a creator and right for a selector. + // The narrow side is set in by the same amount at any size, so the angles never change. const length = $derived(Math.max(size.width, size.height)); const wedge = $derived.by(() => { const w = BUS.blockWidth; - const inset = (length - BUS.narrowSide) / 2; - const corners = isCreator + const inset = BUS.wedgeInset; + const corners: [number, number][] = isCreator ? [[0, 0], [w, inset], [w, length - inset], [0, length]] : [[0, inset], [w, 0], [w, length], [0, length - inset]]; - return corners.map(([x, y]) => `${x},${y}`).join(' '); + return roundedPolygonPath(corners, BUS.cornerRadius); }); + // Port labels follow the global setting unless the block overrides it, like blocks + const showInputLabels = $derived((data.params?.['_showInputLabels'] as boolean | undefined) ?? $portLabelsStore); + const showOutputLabels = $derived((data.params?.['_showOutputLabels'] as boolean | undefined) ?? $portLabelsStore); + // Turn the unrotated frame into the node box: the input side moves like block inputs do const frame = $derived.by(() => { switch (rotation) { @@ -93,19 +102,40 @@ * with its port name while nothing is connected. A selector output picks a * signal from the bus. */ - function commitSignalName(direction: PortDirection, index: number, text: string) { + async function commitSignalName(direction: PortDirection, index: number, text: string) { if (inlineEdit.targetId !== `${id}:${direction}:${index}`) return; editInline(null); const name = text.trim(); - if (name === signalName(direction, index)) return; + const previous = signalName(direction, index); + if (name === previous) return; if (direction === 'output') { historyStore.mutate(() => graphStore.setSelectorSignal(id, index, name)); return; } const wire = get(graphStore.connections).find((c) => c.targetNodeId === id && c.targetPortIndex === index); - if (wire) historyStore.mutate(() => graphStore.updateConnectionLabel(wire.id, name)); - else if (name) historyStore.mutate(() => graphStore.updateNodePortName(id, 'input', index, name)); + if (!wire && !name) return; + + // Selectors picking this signal, here or in subsystems, can follow the rename after asking + const source = { path: graphStore.getCurrentPath(), creatorId: id, input: index }; + const before = graphStore.toJSON(); + const uses = selectorUses(before.nodes, before.connections, source); + const everywhere = + uses.length > 0 && + (await confirmationStore.show({ + title: 'Rename signal everywhere?', + message: `"${previous}" is picked by ${uses.length} Bus Selector ${uses.length === 1 ? 'output' : 'outputs'}. Rename it there too?`, + confirmText: 'Rename everywhere', + cancelText: 'Only here' + })); + + historyStore.mutate(() => { + if (wire) graphStore.updateConnectionLabel(wire.id, name); + else graphStore.updateNodePortName(id, 'input', index, name); + if (!everywhere) return; + const after = graphStore.toJSON(); + graphStore.setSelectorSignalsAt(renamedSignals(after.nodes, after.connections, source, uses)); + }); } let body = $state(null); @@ -115,7 +145,9 @@ if (body && !editingLabel) showTooltip(data.name, body, rotation === 1 || rotation === 3 ? 'right' : 'top'); } + // Only signal names are edited; the bus port on the narrow side is not function startLabelEdit(direction: PortDirection, index: number) { + if (direction === (isCreator ? 'output' : 'input')) return; hideTooltip(); editInline(`${id}:${direction}:${index}`); } @@ -137,8 +169,8 @@ > - - + + @@ -150,8 +182,8 @@ {rotation} {nodeColor} {selected} - showInputLabels={isCreator} - showOutputLabels={!isCreator} + {showInputLabels} + {showOutputLabels} dynamicInputs={isCreator} minInputs={1} inputNames={isCreator ? busCreatorSignals.get(id) : undefined} diff --git a/src/lib/components/nodes/NodePorts.svelte b/src/lib/components/nodes/NodePorts.svelte index 14da54ea..fcb13439 100644 --- a/src/lib/components/nodes/NodePorts.svelte +++ b/src/lib/components/nodes/NodePorts.svelte @@ -397,9 +397,10 @@ cursor: not-allowed; } - /* Ports carrying a bus: a solid arrow, wider across the wire, matching the - * thicker bus wire. Same length along the wire as a normal handle, so wires - * and routing attach at the same point. */ + /* Ports carrying a bus: a hollow arrow like other ports, wider across the wire + * and with a heavier outline to match the thicker bus wire. Same length along + * the wire as a normal handle, so wires and routing attach at the same point. + * Like other ports it fills on hover and selection. */ :global(.node .svelte-flow__handle.handle-bus) { height: 12px; } @@ -411,7 +412,7 @@ } :global(.node .svelte-flow__handle.handle-bus::after) { - display: none; + inset: 1.5px; } :global(.node[data-rotation="0"] .svelte-flow__handle.handle-bus::before) { @@ -429,4 +430,21 @@ :global(.node[data-rotation="3"] .svelte-flow__handle.handle-bus::before) { clip-path: path('M 0 9 L 0 5 Q 0 4 0.7 3.3 L 5.3 0.7 Q 6 0 6.7 0.7 L 11.3 3.3 Q 12 4 12 5 L 12 9 Q 12 10 11 10 L 1 10 Q 0 10 0 9 Z'); } + + /* Inner cutouts, 1.5px inside the outer arrow */ + :global(.node[data-rotation="0"] .svelte-flow__handle.handle-bus::after) { + clip-path: path('M 0.8 0 L 3.2 0 Q 3.7 0 4 0.45 L 6.4 4.05 Q 6.7 4.5 6.4 4.95 L 4 8.55 Q 3.7 9 3.2 9 L 0.8 9 Q 0 9 0 8.2 L 0 0.8 Q 0 0 0.8 0 Z'); + } + + :global(.node[data-rotation="1"] .svelte-flow__handle.handle-bus::after) { + clip-path: path('M 0 0.8 L 0 3.2 Q 0 3.7 0.45 4 L 4.05 6.4 Q 4.5 6.7 4.95 6.4 L 8.55 4 Q 9 3.7 9 3.2 L 9 0.8 Q 9 0 8.2 0 L 0.8 0 Q 0 0 0 0.8 Z'); + } + + :global(.node[data-rotation="2"] .svelte-flow__handle.handle-bus::after) { + clip-path: path('M 6.2 0 L 3.8 0 Q 3.3 0 3 0.45 L 0.6 4.05 Q 0.3 4.5 0.6 4.95 L 3 8.55 Q 3.3 9 3.8 9 L 6.2 9 Q 7 9 7 8.2 L 7 0.8 Q 7 0 6.2 0 Z'); + } + + :global(.node[data-rotation="3"] .svelte-flow__handle.handle-bus::after) { + clip-path: path('M 0 6.2 L 0 3.8 Q 0 3.3 0.45 3 L 4.05 0.6 Q 4.5 0.3 4.95 0.6 L 8.55 3 Q 9 3.3 9 3.8 L 9 6.2 Q 9 7 8.2 7 L 0.8 7 Q 0 7 0 6.2 Z'); + } diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index c0f9a157..2db88405 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -54,11 +54,13 @@ export const INLINE_INPUT = { export const BUS = { /** Block width across the wedge: 2 grid units */ blockWidth: G.x2, - /** Length of the narrow wedge side where the bus attaches: 2 grid units */ - narrowSide: G.x2, + /** How far the narrow side is set in at each end; the same at every size, so the angles never change: 1 grid unit */ + wedgeInset: G.unit, + /** Corner radius of the wedge in pixels */ + cornerRadius: 3, /** Line width of a wire carrying a bus, relative to a plain connection */ wireScale: 2.5, - /** A bus wire starts this far inside its source port, where the solid port is wider than the wire */ + /** A bus wire starts this far inside its source port, which covers the wire end, so the thick line joins without a gap */ sourceInset: 4 } as const; diff --git a/src/lib/stores/graph/buses.ts b/src/lib/stores/graph/buses.ts index 3d05a6b9..3c7076b6 100644 --- a/src/lib/stores/graph/buses.ts +++ b/src/lib/stores/graph/buses.ts @@ -7,7 +7,48 @@ import { NODE_TYPES } from '$lib/constants/nodeTypes'; import { selectedSignals } from '$lib/bus/expand'; import { queueRemoveConnection } from '$lib/pyodide/mutationQueue'; import { createPorts } from './helpers'; -import { getCurrentGraph, updateCurrentNodes, updateCurrentNodesAndConnections } from './state'; +import { + getCurrentGraph, + rootNodes, + updateCurrentNodes, + updateCurrentNodesAndConnections, + updateSubsystemGraph +} from './state'; + +/** + * Set the picked signals of Bus Selector outputs anywhere in the model, e.g. to + * follow a renamed signal. Outputs are named after their signal and keep their wires. + */ +export function setSelectorSignalsAt( + changes: { path: string[]; selectorId: string; output: number; signal: string }[] +): void { + const byLevel = new Map(); + for (const change of changes) { + const key = change.path.join('/'); + byLevel.set(key, [...(byLevel.get(key) ?? []), change]); + } + + for (const group of byLevel.values()) { + const update = (node: NodeInstance): NodeInstance => { + const own = group.filter((c) => c.selectorId === node.id); + if (own.length === 0) return node; + const signals = [...selectedSignals(node)]; + const outputs = [...node.outputs]; + for (const change of own) { + signals[change.output] = change.signal; + if (outputs[change.output]) outputs[change.output] = { ...outputs[change.output], name: change.signal }; + } + return { ...node, params: { ...node.params, signals }, outputs }; + }; + + const path = group[0].path; + if (path.length === 0) { + rootNodes.update((nodes) => new Map([...nodes].map(([id, node]) => [id, update(node)]))); + } else { + updateSubsystemGraph(path, (graph) => ({ ...graph, nodes: graph.nodes.map(update) })); + } + } +} /** * Set the signal one Bus Selector output picks. The output keeps its wires. diff --git a/src/lib/stores/graph/index.ts b/src/lib/stores/graph/index.ts index 7128176f..9f4a5fbf 100644 --- a/src/lib/stores/graph/index.ts +++ b/src/lib/stores/graph/index.ts @@ -98,6 +98,7 @@ export const graphStore = { // ==================== BUS BLOCK OPERATIONS ==================== setSelectedSignals: buses.setSelectedSignals, setSelectorSignal: buses.setSelectorSignal, + setSelectorSignalsAt: buses.setSelectorSignalsAt, // ==================== SUBSYSTEM EVENT OPERATIONS ==================== addSubsystemEvent: subsystemEvents.addSubsystemEvent, diff --git a/src/lib/utils/svgPath.test.ts b/src/lib/utils/svgPath.test.ts new file mode 100644 index 00000000..d22864b6 --- /dev/null +++ b/src/lib/utils/svgPath.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { roundedPolygonPath } from './svgPath'; + +describe('roundedPolygonPath', () => { + it('rounds every corner of a closed polygon', () => { + expect(roundedPolygonPath([[0, 0], [10, 0], [10, 10], [0, 10]], 2)).toBe( + 'M 0 2 Q 0 0 2 0 L 8 0 Q 10 0 10 2 L 10 8 Q 10 10 8 10 L 2 10 Q 0 10 0 8 Z' + ); + }); + + it('clamps the radius to half of the shorter side', () => { + expect(roundedPolygonPath([[0, 0], [2, 0], [2, 10], [0, 10]], 5)).toBe( + 'M 0 1 Q 0 0 1 0 L 1 0 Q 2 0 2 1 L 2 9 Q 2 10 1 10 L 1 10 Q 0 10 0 9 Z' + ); + }); +}); diff --git a/src/lib/utils/svgPath.ts b/src/lib/utils/svgPath.ts new file mode 100644 index 00000000..e80159d6 --- /dev/null +++ b/src/lib/utils/svgPath.ts @@ -0,0 +1,26 @@ +/** + * SVG path helpers + */ + +/** + * Path of a closed polygon with rounded corners. The radius is clamped to half + * of the shorter side at each corner, so short sides never overlap. + */ +export function roundedPolygonPath(points: [number, number][], radius: number): string { + const count = points.length; + let d = ''; + for (let i = 0; i < count; i++) { + const [px, py] = points[(i - 1 + count) % count]; + const [cx, cy] = points[i]; + const [nx, ny] = points[(i + 1) % count]; + const toPrev = Math.hypot(px - cx, py - cy); + const toNext = Math.hypot(nx - cx, ny - cy); + const r = Math.min(radius, toPrev / 2, toNext / 2); + const startX = cx + ((px - cx) / toPrev) * r; + const startY = cy + ((py - cy) / toPrev) * r; + const endX = cx + ((nx - cx) / toNext) * r; + const endY = cy + ((ny - cy) / toNext) * r; + d += `${i === 0 ? 'M' : 'L'} ${startX} ${startY} Q ${cx} ${cy} ${endX} ${endY} `; + } + return `${d}Z`; +} From d74e3edc1aeb9f4e9a2fe434c4943d70c4c22482 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 18:54:59 +0200 Subject: [PATCH 12/17] Draw bus wires twice as thick as connections --- src/lib/components/edges/OrthogonalEdge.svelte | 2 +- src/lib/constants/dimensions.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index c2410b49..3fac2655 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -67,7 +67,7 @@ const ARROW_PATH = 'M -5 -2.5 L -1 -0.5 Q 0 0 -1 0.5 L -5 2.5 Q -6 3 -6 2 L -6 -2 Q -6 -3 -5 -2.5 Z'; /** Wider arrowhead for the thicker bus wire; its base overlaps the wire end */ - const BUS_ARROW_PATH = 'M -6 -3.75 L -1 -0.6 Q 0 0 -1 0.6 L -6 3.75 Q -7 4.2 -7 3.2 L -7 -3.2 Q -7 -4.2 -6 -3.75 Z'; + const BUS_ARROW_PATH = 'M -5.5 -3 L -1 -0.5 Q 0 0 -1 0.5 L -5.5 3 Q -6.5 3.5 -6.5 2.5 L -6.5 -2.5 Q -6.5 -3.5 -5.5 -3 Z'; /** Minimum distance of a segment midpoint handle from an existing waypoint */ const MIN_DISTANCE_FROM_WAYPOINT = 20; diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index 2db88405..b6cec8ed 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -59,7 +59,7 @@ export const BUS = { /** Corner radius of the wedge in pixels */ cornerRadius: 3, /** Line width of a wire carrying a bus, relative to a plain connection */ - wireScale: 2.5, + wireScale: 2, /** A bus wire starts this far inside its source port, which covers the wire end, so the thick line joins without a gap */ sourceInset: 4 } as const; From 7befbffb9d8565d8a2e2da8a5aafc683f3f066dc Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 18:59:36 +0200 Subject: [PATCH 13/17] Size bus port handles like other ports with a heavier outline --- src/lib/components/nodes/NodePorts.svelte | 40 ++++------------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/src/lib/components/nodes/NodePorts.svelte b/src/lib/components/nodes/NodePorts.svelte index fcb13439..8399b845 100644 --- a/src/lib/components/nodes/NodePorts.svelte +++ b/src/lib/components/nodes/NodePorts.svelte @@ -397,54 +397,26 @@ cursor: not-allowed; } - /* Ports carrying a bus: a hollow arrow like other ports, wider across the wire - * and with a heavier outline to match the thicker bus wire. Same length along - * the wire as a normal handle, so wires and routing attach at the same point. - * Like other ports it fills on hover and selection. */ - :global(.node .svelte-flow__handle.handle-bus) { - height: 12px; - } - - :global(.node[data-rotation="1"] .svelte-flow__handle.handle-bus), - :global(.node[data-rotation="3"] .svelte-flow__handle.handle-bus) { - width: 12px; - height: 10px; - } - + /* Ports carrying a bus: the same arrow as other ports with a heavier outline, + * matching the thicker bus wire. Like other ports it fills on hover and selection. */ :global(.node .svelte-flow__handle.handle-bus::after) { inset: 1.5px; } - :global(.node[data-rotation="0"] .svelte-flow__handle.handle-bus::before) { - clip-path: path('M 1 0 L 5 0 Q 6 0 6.7 0.7 L 9.3 5.3 Q 10 6 9.3 6.7 L 6.7 11.3 Q 6 12 5 12 L 1 12 Q 0 12 0 11 L 0 1 Q 0 0 1 0 Z'); - } - - :global(.node[data-rotation="1"] .svelte-flow__handle.handle-bus::before) { - clip-path: path('M 0 1 L 0 5 Q 0 6 0.7 6.7 L 5.3 9.3 Q 6 10 6.7 9.3 L 11.3 6.7 Q 12 6 12 5 L 12 1 Q 12 0 11 0 L 1 0 Q 0 0 0 1 Z'); - } - - :global(.node[data-rotation="2"] .svelte-flow__handle.handle-bus::before) { - clip-path: path('M 9 0 L 5 0 Q 4 0 3.3 0.7 L 0.7 5.3 Q 0 6 0.7 6.7 L 3.3 11.3 Q 4 12 5 12 L 9 12 Q 10 12 10 11 L 10 1 Q 10 0 9 0 Z'); - } - - :global(.node[data-rotation="3"] .svelte-flow__handle.handle-bus::before) { - clip-path: path('M 0 9 L 0 5 Q 0 4 0.7 3.3 L 5.3 0.7 Q 6 0 6.7 0.7 L 11.3 3.3 Q 12 4 12 5 L 12 9 Q 12 10 11 10 L 1 10 Q 0 10 0 9 Z'); - } - /* Inner cutouts, 1.5px inside the outer arrow */ :global(.node[data-rotation="0"] .svelte-flow__handle.handle-bus::after) { - clip-path: path('M 0.8 0 L 3.2 0 Q 3.7 0 4 0.45 L 6.4 4.05 Q 6.7 4.5 6.4 4.95 L 4 8.55 Q 3.7 9 3.2 9 L 0.8 9 Q 0 9 0 8.2 L 0 0.8 Q 0 0 0.8 0 Z'); + clip-path: path('M 0.6 0 L 3.5 0 Q 3.9 0 4.2 0.3 L 6.1 2.2 Q 6.4 2.5 6.1 2.8 L 4.2 4.7 Q 3.9 5 3.5 5 L 0.6 5 Q 0 5 0 4.4 L 0 0.6 Q 0 0 0.6 0 Z'); } :global(.node[data-rotation="1"] .svelte-flow__handle.handle-bus::after) { - clip-path: path('M 0 0.8 L 0 3.2 Q 0 3.7 0.45 4 L 4.05 6.4 Q 4.5 6.7 4.95 6.4 L 8.55 4 Q 9 3.7 9 3.2 L 9 0.8 Q 9 0 8.2 0 L 0.8 0 Q 0 0 0 0.8 Z'); + clip-path: path('M 0 0.6 L 0 3.5 Q 0 3.9 0.3 4.2 L 2.2 6.1 Q 2.5 6.4 2.8 6.1 L 4.7 4.2 Q 5 3.9 5 3.5 L 5 0.6 Q 5 0 4.4 0 L 0.6 0 Q 0 0 0 0.6 Z'); } :global(.node[data-rotation="2"] .svelte-flow__handle.handle-bus::after) { - clip-path: path('M 6.2 0 L 3.8 0 Q 3.3 0 3 0.45 L 0.6 4.05 Q 0.3 4.5 0.6 4.95 L 3 8.55 Q 3.3 9 3.8 9 L 6.2 9 Q 7 9 7 8.2 L 7 0.8 Q 7 0 6.2 0 Z'); + clip-path: path('M 6.4 0 L 3.5 0 Q 3.1 0 2.8 0.3 L 0.9 2.2 Q 0.6 2.5 0.9 2.8 L 2.8 4.7 Q 3.1 5 3.5 5 L 6.4 5 Q 7 5 7 4.4 L 7 0.6 Q 7 0 6.4 0 Z'); } :global(.node[data-rotation="3"] .svelte-flow__handle.handle-bus::after) { - clip-path: path('M 0 6.2 L 0 3.8 Q 0 3.3 0.45 3 L 4.05 0.6 Q 4.5 0.3 4.95 0.6 L 8.55 3 Q 9 3.3 9 3.8 L 9 6.2 Q 9 7 8.2 7 L 0.8 7 Q 0 7 0 6.2 Z'); + clip-path: path('M 0 6.4 L 0 3.5 Q 0 3.1 0.3 2.8 L 2.2 0.9 Q 2.5 0.6 2.8 0.9 L 4.7 2.8 Q 5 3.1 5 3.5 L 5 6.4 Q 5 7 4.4 7 L 0.6 7 Q 0 7 0 6.4 Z'); } From f4e5de67d773cf00f8477087a052ecefb6493987 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 19:05:17 +0200 Subject: [PATCH 14/17] Remove the name tooltip from bus blocks --- src/lib/components/nodes/BusBlockNode.svelte | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/lib/components/nodes/BusBlockNode.svelte b/src/lib/components/nodes/BusBlockNode.svelte index dd3bf529..b876f494 100644 --- a/src/lib/components/nodes/BusBlockNode.svelte +++ b/src/lib/components/nodes/BusBlockNode.svelte @@ -17,13 +17,12 @@ import { openNodeDialog } from '$lib/stores/nodeDialog'; import { selectedNodeHighlight } from '$lib/stores/hoveredHandle'; import { busCreatorSignals } from '$lib/stores/busView.svelte'; - import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte'; import NodePorts from './NodePorts.svelte'; /** * Bus Creator and Bus Selector drawn as a narrow wedge instead of a block. * The wide side carries the separate signals, the narrow side the bus. - * Signal ports are always labeled; the block name shows on hover. + * Port labels follow the port label settings, like blocks. */ interface Props { id: string; @@ -138,24 +137,15 @@ }); } - let body = $state(null); - - // The block name shows on hover, but not over a signal name being edited - function handleMouseEnter() { - if (body && !editingLabel) showTooltip(data.name, body, rotation === 1 || rotation === 3 ? 'right' : 'top'); - } - // Only signal names are edited; the bus port on the narrow side is not function startLabelEdit(direction: PortDirection, index: number) { if (direction === (isCreator ? 'output' : 'input')) return; - hideTooltip(); editInline(`${id}:${direction}:${index}`); }
From 71c03b9b6c594e4c7916cec658107e30f3d71f6d Mon Sep 17 00:00:00 2001 From: milanofthe Date: Mon, 14 Sep 2026 19:24:08 +0200 Subject: [PATCH 15/17] Give bus blocks their own library category and show their wedge in library previews --- src/lib/components/nodes/BusBlockNode.svelte | 63 ++------------ src/lib/components/nodes/BusWedge.svelte | 86 +++++++++++++++++++ src/lib/components/nodes/NodePreview.svelte | 26 +++++- src/lib/components/panels/NodeLibrary.svelte | 5 +- .../library-detail/CanvasBlockPreview.svelte | 33 +++++-- src/lib/constants/nodeTypes.ts | 3 + src/lib/constants/python.ts | 1 + src/lib/nodes/buses.ts | 6 +- 8 files changed, 153 insertions(+), 70 deletions(-) create mode 100644 src/lib/components/nodes/BusWedge.svelte diff --git a/src/lib/components/nodes/BusBlockNode.svelte b/src/lib/components/nodes/BusBlockNode.svelte index b876f494..9a108261 100644 --- a/src/lib/components/nodes/BusBlockNode.svelte +++ b/src/lib/components/nodes/BusBlockNode.svelte @@ -6,18 +6,17 @@ import { graphStore } from '$lib/stores/graph'; import { historyStore } from '$lib/stores/history'; import { inlineEdit, editInline } from '$lib/stores/inlineEdit.svelte'; - import { busSelectorOptions } from '$lib/stores/busView.svelte'; + import { busCreatorSignals, busSelectorOptions } from '$lib/stores/busView.svelte'; import { selectedSignals } from '$lib/bus/expand'; import { renamedSignals, selectorUses } from '$lib/bus/rename'; import { confirmationStore } from '$lib/stores/confirmation'; import { portLabelsStore } from '$lib/stores/portLabels'; - import { roundedPolygonPath } from '$lib/utils/svgPath'; import { NODE_TYPES } from '$lib/constants/nodeTypes'; - import { BUS, busBlockDimensions } from '$lib/constants/dimensions'; + import { busBlockDimensions } from '$lib/constants/dimensions'; import { openNodeDialog } from '$lib/stores/nodeDialog'; import { selectedNodeHighlight } from '$lib/stores/hoveredHandle'; - import { busCreatorSignals } from '$lib/stores/busView.svelte'; import NodePorts from './NodePorts.svelte'; + import BusWedge from './BusWedge.svelte'; /** * Bus Creator and Bus Selector drawn as a narrow wedge instead of a block. @@ -39,32 +38,10 @@ const size = $derived(busBlockDimensions(data.inputs.length, data.outputs.length, rotation)); const nodeColor = $derived(data.color || 'var(--accent)'); - // Trapezoid in the unrotated frame, wide side left for a creator and right for a selector. - // The narrow side is set in by the same amount at any size, so the angles never change. - const length = $derived(Math.max(size.width, size.height)); - const wedge = $derived.by(() => { - const w = BUS.blockWidth; - const inset = BUS.wedgeInset; - const corners: [number, number][] = isCreator - ? [[0, 0], [w, inset], [w, length - inset], [0, length]] - : [[0, inset], [w, 0], [w, length], [0, length - inset]]; - return roundedPolygonPath(corners, BUS.cornerRadius); - }); - // Port labels follow the global setting unless the block overrides it, like blocks const showInputLabels = $derived((data.params?.['_showInputLabels'] as boolean | undefined) ?? $portLabelsStore); const showOutputLabels = $derived((data.params?.['_showOutputLabels'] as boolean | undefined) ?? $portLabelsStore); - // Turn the unrotated frame into the node box: the input side moves like block inputs do - const frame = $derived.by(() => { - switch (rotation) { - case 1: return `translate(${length}, 0) rotate(90)`; - case 2: return `translate(${BUS.blockWidth}, ${length}) rotate(180)`; - case 3: return `translate(0, ${BUS.blockWidth}) rotate(270)`; - default: return undefined; - } - }); - // Re-measure handles when the wedge changes size or orientation $effect(() => { void size; @@ -155,12 +132,9 @@ openNodeDialog(id); }} > - - - - - - +
+ +
diff --git a/src/lib/components/nodes/BusWedge.svelte b/src/lib/components/nodes/BusWedge.svelte new file mode 100644 index 00000000..1b17745d --- /dev/null +++ b/src/lib/components/nodes/BusWedge.svelte @@ -0,0 +1,86 @@ + + + + + + + + + + diff --git a/src/lib/components/nodes/NodePreview.svelte b/src/lib/components/nodes/NodePreview.svelte index 75b152b8..377b2944 100644 --- a/src/lib/components/nodes/NodePreview.svelte +++ b/src/lib/components/nodes/NodePreview.svelte @@ -1,6 +1,9 @@ -
- {node.name} -
+{#if isBus} +
+ + {node.name} +
+{:else} +
+ {node.name} +
+{/if} + } diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 3fac2655..589e3229 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -36,7 +36,7 @@ import { GRID_SIZE, EDGE_SOURCE_OFFSET, EDGE_TARGET_OFFSET, EDGE_CORNER_RADIUS } from '$lib/routing/constants'; import InlineInput from '$lib/components/InlineInput.svelte'; import { BUS } from '$lib/constants/dimensions'; - import { busWireSignals, busCreatorWires } from '$lib/stores/busView.svelte'; + import { busWires, busCreatorWires } from '$lib/stores/busView.svelte'; import type { Direction, RouteResult } from '$lib/routing'; import type { Waypoint } from '$lib/types/nodes'; @@ -198,12 +198,12 @@ } // Path ends at the handle tips: small inset at the source, room for the arrowhead at the target - // Number of signals on a wire carrying a bus; such wires are drawn thicker - const busSignals = $derived(busWireSignals.get(id)); + // Wires carrying a bus are drawn thicker + const carriesBus = $derived(busWires.has(id)); // A bus wire starts inside the solid bus port, so the thick line joins it without a gap const adjustedSource = $derived( - alongFacing(sourceX, sourceY, sourcePosition, -(busSignals !== undefined ? BUS.sourceInset : EDGE_SOURCE_OFFSET)) + alongFacing(sourceX, sourceY, sourcePosition, -(carriesBus ? BUS.sourceInset : EDGE_SOURCE_OFFSET)) ); const adjustedTarget = $derived(alongFacing(targetX, targetY, targetPosition, EDGE_TARGET_OFFSET)); @@ -402,8 +402,8 @@ @@ -441,7 +441,7 @@ (); +/** Wires carrying a bus */ +export const busWires = new SvelteSet(); /** Bus Creator ID to the signal name of each of its inputs */ export const busCreatorSignals = new SvelteMap(); @@ -36,6 +36,13 @@ function sync(target: SvelteMap, next: Map, same: (a: T } } +function syncSet(target: SvelteSet, next: Set): void { + for (const key of [...target]) { + if (!next.has(key)) target.delete(key); + } + for (const key of next) target.add(key); +} + const sameNames = (a: string[], b: string[]) => a.length === b.length && a.every((name, i) => name === b[i]); const sameIndices = (a: number[], b: number[]) => a.length === b.length && a.every((index, i) => index === b[i]); @@ -48,7 +55,7 @@ export function updateBusView( path: string[], connections: Connection[] ): void { - const wires = new Map(); + const wires = new Set(); const creators = new Map(); const ports = new Map(); const selectorOptions = new Map(); @@ -59,8 +66,7 @@ export function updateBusView( const level = analysis.levelAt(path); if (level) { for (const connection of connections) { - const structure = analysis.structureOut(level, connection.sourceNodeId, connection.sourcePortIndex); - if (structure) wires.set(connection.id, signalLeaves(structure).length); + if (analysis.structureOut(level, connection.sourceNodeId, connection.sourcePortIndex)) wires.add(connection.id); if (level.nodes.get(connection.targetNodeId)?.type === NODE_TYPES.BUS_CREATOR) creatorWires.add(connection.id); } for (const node of level.nodeList) { @@ -75,12 +81,9 @@ export function updateBusView( } } - sync(busWireSignals, wires, (a, b) => a === b); + syncSet(busWires, wires); sync(busCreatorSignals, creators, sameNames); sync(busPorts, ports, (a, b) => sameIndices(a.inputs, b.inputs) && sameIndices(a.outputs, b.outputs)); sync(busSelectorOptions, selectorOptions, sameNames); - for (const id of [...busCreatorWires]) { - if (!creatorWires.has(id)) busCreatorWires.delete(id); - } - for (const id of creatorWires) busCreatorWires.add(id); + syncSet(busCreatorWires, creatorWires); }