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 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/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 new file mode 100644 index 00000000..0922952d --- /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 unknown 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..77b0450d --- /dev/null +++ b/src/lib/bus/expand.ts @@ -0,0 +1,349 @@ +/** + * 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; + /** 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 */ +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; + +/** 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; +} + +/** 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)) + ); +} + +/** 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; + 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; +} + +export 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), + 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; + } + 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, elementNames }; +} + +/** + * 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/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/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 5d3be6e8..bf6f5dd1 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -38,6 +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 { 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'; @@ -49,6 +52,7 @@ toFlowEdge, toEventNode, toAnnotationNode, + isBlockFlowNode, rotateSelectedNodes, flipSelectedNodesHorizontal, flipSelectedNodesVertical, @@ -271,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 @@ -289,6 +293,7 @@ // Custom node types - will add more for different shapes const nodeTypes: NodeTypes = { pathview: BaseNode, + busBlock: BusBlockNode, eventNode: EventNode, annotation: AnnotationNode }; @@ -334,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; @@ -512,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 @@ -605,6 +610,8 @@ function rebuildEdges(connections: Connection[]): void { const visibleIds = getVisibleNodeIds(); const currentEdgeSelection = new Map(edges.map((e) => [e.id, e.selected])); + // 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) => { @@ -653,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, @@ -766,9 +773,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; } @@ -1046,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; } @@ -1063,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 new file mode 100644 index 00000000..363a89a4 --- /dev/null +++ b/src/lib/components/InlineInput.svelte @@ -0,0 +1,213 @@ + + + + +{#if options.length > 0 && listPosition} +
+ {#each options as option, i (option.text)} +
pick(event, option)} + onpointerenter={() => (activeIndex = i)} + > + {#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/canvas/flowConverters.ts b/src/lib/components/canvas/flowConverters.ts index 712ebc09..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 */ 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/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/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 c5c5c485..589e3229 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -30,11 +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 { EDGE_LABEL } from '$lib/constants/dimensions'; + import InlineInput from '$lib/components/InlineInput.svelte'; + import { BUS } from '$lib/constants/dimensions'; + import { busWires, busCreatorWires } from '$lib/stores/busView.svelte'; import type { Direction, RouteResult } from '$lib/routing'; import type { Waypoint } from '$lib/types/nodes'; @@ -61,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 -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; @@ -190,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)); + // 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, -(carriesBus ? BUS.sourceInset : EDGE_SOURCE_OFFSET)) + ); const adjustedTarget = $derived(alongFacing(targetX, targetY, targetPosition, EDGE_TARGET_OFFSET)); /** @@ -298,78 +312,47 @@ return midpoints; }); + // 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); - - // Editor width follows the typed text - let draftLength = $state(0); + const isEditingLabel = $derived(inlineEdit.targetId === id); - const labelAnchor = $derived.by(() => { + // Middle of the longest route segment, where the label sits + const segmentAnchor = $derived.by(() => { if (!label && !isEditingLabel) 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(); - 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)); } - /** - * 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(); @@ -419,7 +402,8 @@ @@ -457,17 +441,19 @@ - {#if label && !isEditingLabel && labelAnchor} + + {#if label && !isEditingLabel && labelAnchor && !busCreatorWires.has(id)} {label} - + editInline(null)} /> {/if} @@ -539,26 +519,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/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index b541d0ca..7ee48d2a 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -1,6 +1,6 @@ + + +
{ + e.stopPropagation(); + openNodeDialog(id); + }} +> +
+ +
+ + + +
+ +{#snippet labelEditor(direction: PortDirection, index: number)} + commitSignalName(direction, index, text)} + onCancel={() => editInline(null)} + /> +{/snippet} + + 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/NodePorts.svelte b/src/lib/components/nodes/NodePorts.svelte new file mode 100644 index 00000000..8399b845 --- /dev/null +++ b/src/lib/components/nodes/NodePorts.svelte @@ -0,0 +1,422 @@ + + + +{#if hasVisibleInputLabels} + {#each inputs as port, i} + + handleLabelDoubleClick(e, 'input', i)} + > + {#if isEditing('input', i)} + {@render labelEditor?.('input', i)} + {:else} + {truncatePortLabel(inputName(port, i))} + {/if} + + {/each} +{/if} +{#if hasVisibleOutputLabels} + {#each outputs as port, i} + + handleLabelDoubleClick(e, 'output', i)} + > + {#if isEditing('output', i)} + {@render labelEditor?.('output', i)} + {:else} + {truncatePortLabel(port.name)} + {/if} + + {/each} +{/if} + + +{#if dynamicInputs && selected} +
+ + +
+{/if} + + +{#if dynamicOutputs && selected} +
+ + +
+{/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/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}