@field/cascade 0.8.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +36 -3
- package/CHANGELOG.md +401 -0
- package/DESIGN.md +14 -0
- package/README.md +41 -26
- package/dist/assets/CodeEditor-4O7FQ__R.js +623 -0
- package/dist/assets/CodePanel-BU-autmq.js +2 -0
- package/dist/assets/{CustomNodeDialog-BQbEhIwL.js → CustomNodeDialog-BRpDfOV5.js} +1 -1
- package/dist/assets/OpenGraphDialog-BwbRlC0X.js +1 -0
- package/dist/assets/ProjectSettings-DLOXLxcA.js +2 -0
- package/dist/assets/SettingsDialog-Bz8JxISH.js +1 -0
- package/dist/assets/{VersionHistoryDialog-D6VBzSQ0.js → VersionHistoryDialog-D5Z0nJ5-.js} +2 -2
- package/dist/assets/{core-BInLG4gw.js → core-Yqi0Ni61.js} +8 -8
- package/dist/assets/{cssMode-DfR2qmYG.js → cssMode-CxUzc4vp.js} +1 -1
- package/dist/assets/definitions-GreEMXLV.js +1 -0
- package/dist/assets/{editor.api-Ck8_vgQV.js → editor.api-BNFb25CX.js} +1 -1
- package/dist/assets/{freemarker2-fVn10Wn3.js → freemarker2-0ZDnWxeM.js} +1 -1
- package/dist/assets/geo-u0n_jOsD.js +1 -0
- package/dist/assets/{handlebars-BCSchLT2.js → handlebars-Cy18MoaD.js} +1 -1
- package/dist/assets/{html-CVpSv_k9.js → html-B-2Dx7nq.js} +1 -1
- package/dist/assets/{htmlMode-Da4ESTv0.js → htmlMode-DUAc9B_d.js} +1 -1
- package/dist/assets/{image-BLn--oSF.js → image-cAa-Nu9O.js} +3 -3
- package/dist/assets/index-5ZtDAuy2.js +220 -0
- package/dist/assets/index-7vae6YFC.css +1 -0
- package/dist/assets/index-client-DqYmKstU.js +1 -0
- package/dist/assets/{javascript-BpX0Gvcf.js → javascript-DeQsDqP5.js} +1 -1
- package/dist/assets/{jsonMode-CsLqaou7.js → jsonMode-BiFwJVSk.js} +1 -1
- package/dist/assets/legacy-CTEeXeqF.js +2 -0
- package/dist/assets/{liquid-efUz51Wf.js → liquid-BLoHH68a.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-DCi5-K0n.js → lspLanguageFeatures-CNFgm1Kv.js} +1 -1
- package/dist/assets/math-DioIQOPJ.js +1 -0
- package/dist/assets/{mdx-DP_sjGVy.js → mdx-CFXQVNfs.js} +1 -1
- package/dist/assets/pop-DC_TQG9t.js +1 -0
- package/dist/assets/{python-aAuP3LUi.js → python-Cf7S7p2E.js} +1 -1
- package/dist/assets/{razor-7M6sMmC5.js → razor-CMHBcG-g.js} +1 -1
- package/dist/assets/studioScenes-8sq9FfFg.js +1 -0
- package/dist/assets/{tsMode-Bf4CnoYK.js → tsMode-BEih0GDj.js} +1 -1
- package/dist/assets/{typescript-ddhYBGyT.js → typescript-COEKYD_a.js} +1 -1
- package/dist/assets/{workers-X5la8DDT.js → workers-BNhAhMNn.js} +1 -1
- package/dist/assets/{xml-BzbXa3Ps.js → xml-CPiMpnPl.js} +1 -1
- package/dist/assets/{yaml-CXs-VzxL.js → yaml-Bgp1ITLR.js} +1 -1
- package/dist/cli/Graph-ZJJTRQTM.js +2975 -0
- package/dist/cli/chunk-2KXB47FP.js +115 -0
- package/dist/cli/chunk-35D343DC.js +1447 -0
- package/dist/cli/chunk-36P5K3SD.js +631 -0
- package/dist/cli/chunk-3JTJHF7V.js +267 -0
- package/dist/cli/chunk-4UDFYF5G.js +132 -0
- package/dist/cli/chunk-53NMBJAO.js +35 -0
- package/dist/cli/chunk-A4OAMOZH.js +310 -0
- package/dist/cli/chunk-BIJO5OFS.js +71 -0
- package/dist/cli/chunk-BP7UKF36.js +376 -0
- package/dist/cli/chunk-CKS2FQWR.js +89 -0
- package/dist/cli/chunk-CO22XHNG.js +593 -0
- package/dist/cli/chunk-D6BUOHUG.js +634 -0
- package/dist/cli/chunk-DEQSEFFH.js +153 -0
- package/dist/cli/chunk-DUIUFVU2.js +241 -0
- package/dist/cli/chunk-EUIAC3U3.js +3006 -0
- package/dist/cli/chunk-FCDXJ6EK.js +2208 -0
- package/dist/cli/chunk-GFS6W3C5.js +308 -0
- package/dist/cli/chunk-HFHWYL55.js +139 -0
- package/dist/cli/chunk-HSNOBIVT.js +263 -0
- package/dist/cli/chunk-ICH7N5PN.js +26 -0
- package/dist/cli/chunk-J7VK7MNF.js +418 -0
- package/dist/cli/chunk-KH4MUBKF.js +1551 -0
- package/dist/cli/chunk-MIIEFUGG.js +623 -0
- package/dist/cli/chunk-OWWBYLHC.js +136 -0
- package/dist/cli/chunk-POMMNKZZ.js +672 -0
- package/dist/cli/chunk-QBBKRNGY.js +90 -0
- package/dist/cli/chunk-QQPA2ZHH.js +273 -0
- package/dist/cli/chunk-RLCAJBVR.js +224 -0
- package/dist/cli/chunk-RQZ4SX5R.js +1125 -0
- package/dist/cli/chunk-RUU3YWXU.js +99 -0
- package/dist/cli/chunk-RXDIMXN5.js +98 -0
- package/dist/cli/chunk-SGJAET2L.js +197 -0
- package/dist/cli/chunk-VVGSLVSF.js +151 -0
- package/dist/cli/chunk-WD62JIP6.js +118 -0
- package/dist/cli/chunk-WIH4TEIS.js +104 -0
- package/dist/cli/chunk-WKURDVYP.js +1119 -0
- package/dist/cli/chunk-WPUDGEFY.js +20 -0
- package/dist/cli/chunk-XWDSKG7N.js +1301 -0
- package/dist/cli/chunk-YAT5Y25U.js +121 -0
- package/dist/cli/chunk-ZL4JTG5Y.js +37 -0
- package/dist/cli/cliCommands-UQSFGEHF.js +3464 -0
- package/dist/cli/commands-Q23M26QM.js +1292 -0
- package/dist/cli/definitions-HF2FTHYU.js +32 -0
- package/dist/cli/doctorCommand-B7BPQMN6.js +76 -0
- package/dist/cli/edit-JHCPTQFT.js +28 -0
- package/dist/cli/editorCommands-UIF5YLUZ.js +33 -0
- package/dist/cli/encode-HAUZTYPT.js +17 -0
- package/dist/cli/frames-LSAUBZ7P.js +16 -0
- package/dist/cli/geo-4RNK3QVN.js +43 -0
- package/dist/cli/graphCommands-6BMAKGQE.js +184 -0
- package/dist/cli/graphFile-VPSQ6OF7.js +286 -0
- package/dist/cli/headlessCanvas-5XLDZT7M.js +15 -0
- package/dist/cli/helpCommand-3VRE3KVO.js +155 -0
- package/dist/cli/index.js +106 -14207
- package/dist/cli/math-ZRMLKU2D.js +16 -0
- package/dist/cli/mcp-ZLEEMYGF.js +164 -0
- package/dist/cli/parity-KINARCP2.js +328 -0
- package/dist/cli/pop-USDFXB7H.js +39 -0
- package/dist/cli/project-K3QIPGBB.js +12 -0
- package/dist/cli/projectDefinition-DMTZN2NE.js +27 -0
- package/dist/cli/projectRuntime-Z5PJOH47.js +48 -0
- package/dist/cli/registerStandardNodes-S2VLMF6J.js +3081 -0
- package/dist/cli/render-YURAPPUI.js +436 -0
- package/dist/cli/runCommand-PI764LEG.js +198 -0
- package/dist/cli/runner-RA6UKEPD.js +37 -0
- package/dist/cli/runtime/image.ts +277 -0
- package/dist/cli/runtime/stage.ts +74 -11
- package/dist/cli/stageRunner-KUNIMQKJ.js +10 -0
- package/dist/cli/verbs-RCE3K4VQ.js +19 -0
- package/dist/cli/webBuild-GT4PZT5D.js +37 -0
- package/dist/contracts/bundle.d.ts +75 -0
- package/dist/contracts/bundle.js +112 -0
- package/dist/contracts/definition.d.ts +67 -4
- package/dist/contracts/document.d.ts +29 -0
- package/dist/contracts/documentFormat.d.ts +18 -0
- package/dist/contracts/documentFormat.js +195 -0
- package/dist/contracts/editor.d.ts +189 -0
- package/dist/contracts/editor.js +2 -0
- package/dist/contracts/index.d.ts +3 -0
- package/dist/contracts/index.js +3 -0
- package/dist/contracts/validate.js +59 -3
- package/dist/contracts/values.d.ts +3 -3
- package/dist/contracts/values.js +1 -0
- package/dist/image.d.ts +141 -0
- package/dist/image.js +127 -0
- package/dist/index.html +4 -4
- package/dist/player/app.js +17 -5
- package/dist/player/editor.js +1919 -0
- package/dist/player/editorEngine.js +477 -0
- package/dist/runtime/builtins/core/bundle.d.ts +70 -0
- package/dist/runtime/builtins/core/bundle.js +81 -0
- package/dist/runtime/builtins/core/index.d.ts +3 -2
- package/dist/runtime/builtins/core/index.js +6 -2
- package/dist/runtime/builtins/core/routing.d.ts +27 -0
- package/dist/runtime/builtins/core/routing.js +28 -0
- package/dist/runtime/builtins/headless.d.ts +19 -0
- package/dist/runtime/builtins/headless.js +25 -0
- package/dist/runtime/builtins/image/contactSheet.d.ts +226 -0
- package/dist/runtime/builtins/image/contactSheet.js +199 -0
- package/dist/runtime/builtins/image/index.d.ts +9 -0
- package/dist/runtime/builtins/image/index.js +9 -0
- package/dist/runtime/edit/document.d.ts +94 -0
- package/dist/runtime/edit/document.js +194 -0
- package/dist/runtime/edit/graphEditor.d.ts +223 -0
- package/dist/runtime/edit/graphEditor.js +893 -0
- package/dist/runtime/edit/index.d.ts +12 -0
- package/dist/runtime/edit/index.js +6 -0
- package/dist/runtime/edit/layout.d.ts +50 -0
- package/dist/runtime/edit/layout.js +126 -0
- package/dist/runtime/edit/operations.d.ts +65 -0
- package/dist/runtime/edit/operations.js +57 -0
- package/dist/runtime/edit/parameters.d.ts +75 -0
- package/dist/runtime/edit/parameters.js +284 -0
- package/dist/runtime/edit/read.d.ts +35 -0
- package/dist/runtime/edit/read.js +65 -0
- package/dist/runtime/editor/index.d.ts +104 -0
- package/dist/runtime/editor/index.js +439 -0
- package/dist/runtime/expressions/bundlePaths.d.ts +37 -0
- package/dist/runtime/expressions/bundlePaths.js +55 -0
- package/dist/runtime/expressions/index.d.ts +2 -0
- package/dist/runtime/expressions/index.js +1 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/params/animator.d.ts +5 -0
- package/dist/runtime/params/animator.js +16 -0
- package/dist/runtime/runtime.js +238 -12
- package/dist/runtime/seed.d.ts +29 -0
- package/dist/runtime/seed.js +52 -0
- package/dist/runtime/types.d.ts +12 -0
- package/dist/stage.js +43 -9
- package/dist/studio/panel.d.ts +2 -0
- package/doc/CLI_REFERENCE.md +171 -0
- package/doc/EDITOR_EXPORT.md +235 -0
- package/doc/HEADLESS_GPU.md +27 -0
- package/doc/MCP.md +69 -0
- package/doc/NODE_REFERENCE.md +208 -0
- package/doc/PROJECT_AUTHORING.md +258 -1
- package/package.json +29 -8
- package/dist/assets/CodeEditor-BEk-kIBd.js +0 -623
- package/dist/assets/CodePanel-CeSdjXkg.js +0 -2
- package/dist/assets/ExportDialog-CjTbbTZJ.js +0 -358
- package/dist/assets/ExportDialog-DOq0flMV.css +0 -1
- package/dist/assets/OpenGraphDialog-DCwH9itM.js +0 -1
- package/dist/assets/ProjectSettings-Dg7M5xt9.js +0 -2
- package/dist/assets/SettingsDialog-e7GgsjB9.js +0 -1
- package/dist/assets/definitions-DZpOjWKR.js +0 -1
- package/dist/assets/geo-D2VPjAdt.js +0 -1
- package/dist/assets/index-CMiBdEuT.js +0 -212
- package/dist/assets/index-DMIuml9a.css +0 -1
- package/dist/assets/index-client-BI2H82FJ.js +0 -1
- package/dist/assets/legacy-ZA7textG.js +0 -2
- package/dist/assets/math-B6hpxPJO.js +0 -1
- package/dist/assets/pop-CYUIjT6x.js +0 -1
- package/dist/assets/projectSettingsApi-C1WvIwfP.js +0 -1
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editing a `.cascade` document: add and remove nodes, wire and unwire ports,
|
|
3
|
+
* set parameters, and group nodes into subnets.
|
|
4
|
+
*
|
|
5
|
+
* One implementation behind every surface — `cascade node add`, the MCP
|
|
6
|
+
* server's `node_add`, and anything else that edits a graph without Studio —
|
|
7
|
+
* so a command and a tool call cannot disagree about what an edit means.
|
|
8
|
+
*
|
|
9
|
+
* Three rules hold for every operation, because they are what keeps a graph
|
|
10
|
+
* an agent edited readable to the person who opens it afterwards:
|
|
11
|
+
*
|
|
12
|
+
* - **Minimal diffs.** Only the keys an operation is about change. Node ids are
|
|
13
|
+
* stable, untouched nodes keep their place in the file, a new key goes where
|
|
14
|
+
* Studio would write it, and a rewired connection is rewritten in place
|
|
15
|
+
* rather than removed and appended. An edit followed by its inverse leaves
|
|
16
|
+
* the file byte-identical (tests/graph-edit.test.ts).
|
|
17
|
+
* - **Placed, not piled.** A node the editor creates is positioned next to what
|
|
18
|
+
* it is wired to and in line with the graph's existing flow (`layout.ts`).
|
|
19
|
+
* - **Subnets stay closed.** A wire never crosses a subnet boundary directly;
|
|
20
|
+
* moving nodes in or out of a subnet rewires through its `Input` and
|
|
21
|
+
* `Output` nodes, creating them when needed and removing them when unused.
|
|
22
|
+
*
|
|
23
|
+
* The editor is pure: it edits a parsed document in memory and resolves node
|
|
24
|
+
* definitions through a callback. Reading, validating and writing files is the
|
|
25
|
+
* host's job (`src/cli/graphFile.ts`).
|
|
26
|
+
*/
|
|
27
|
+
import { INPUT, NODE_KEY_ORDER, OUTPUT, SUBNET, integer, moduleOf, parentOf, portsFromDefinition, positionOf, readWire, sameEndpoint, setOrderedKey, setPosition, subnetPorts, typesConnect, wireLabel, wireStyleOf, writeWire, } from "./document.js";
|
|
28
|
+
import { flowStep, isOccupied, placeNode } from "./layout.js";
|
|
29
|
+
import { applyParameterChange, parameterSlot } from "./parameters.js";
|
|
30
|
+
/** A refused edit. The document is left as it was before the operation began. */
|
|
31
|
+
export class GraphEditError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "GraphEditError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class GraphEditor {
|
|
38
|
+
document;
|
|
39
|
+
options;
|
|
40
|
+
warnings = [];
|
|
41
|
+
constructor(document, options) {
|
|
42
|
+
this.document = document;
|
|
43
|
+
this.options = options;
|
|
44
|
+
if (!Array.isArray(document.nodes))
|
|
45
|
+
throw new GraphEditError("Graph document must contain a nodes array");
|
|
46
|
+
}
|
|
47
|
+
// -------------------------------------------------------------------------
|
|
48
|
+
// Nodes
|
|
49
|
+
addNode(input) {
|
|
50
|
+
const parent = input.parent ? this.requireContainer(input.parent) : null;
|
|
51
|
+
const id = input.id ?? this.uniqueId(defaultIdFor(input.module));
|
|
52
|
+
assertValidId(id);
|
|
53
|
+
if (this.findNode(id))
|
|
54
|
+
throw new GraphEditError(`A node with id "${id}" already exists`);
|
|
55
|
+
const source = sourceFor(input.module);
|
|
56
|
+
const node = { id, module: input.module, position: [0, 0] };
|
|
57
|
+
if (source)
|
|
58
|
+
node.source = source;
|
|
59
|
+
if (input.comment)
|
|
60
|
+
node.comment = input.comment;
|
|
61
|
+
if (parent)
|
|
62
|
+
node.parent = parent.id;
|
|
63
|
+
this.insertNode(node, parent?.id ?? null);
|
|
64
|
+
for (const [name, value] of Object.entries(input.props ?? {})) {
|
|
65
|
+
this.setParameterOn(node, name, { value, fromText: input.fromText === true });
|
|
66
|
+
}
|
|
67
|
+
setPosition(node, input.position ?? this.placeFor(node));
|
|
68
|
+
return { node: this.summary(node) };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Remove a node and every wire touching it. A subnet with children is
|
|
72
|
+
* refused unless `recursive`, because removing a container silently would
|
|
73
|
+
* remove everything in it.
|
|
74
|
+
*/
|
|
75
|
+
removeNode(id, options = {}) {
|
|
76
|
+
const node = this.requireNode(id);
|
|
77
|
+
const descendants = this.descendantsOf(id);
|
|
78
|
+
if (descendants.length && !options.recursive) {
|
|
79
|
+
throw new GraphEditError(`${id} contains ${descendants.length} node${descendants.length === 1 ? "" : "s"}; remove with --recursive, or dissolve it with "subnet dissolve"`);
|
|
80
|
+
}
|
|
81
|
+
const parent = parentOf(node);
|
|
82
|
+
const boundary = parent ? this.boundaryIndex(node) : null;
|
|
83
|
+
if (boundary && parent) {
|
|
84
|
+
return { removed: [id], wires: this.removeBoundary(this.requireNode(parent), boundary.kind, boundary.index) };
|
|
85
|
+
}
|
|
86
|
+
const removed = new Set([id, ...descendants.map((child) => child.id)]);
|
|
87
|
+
const wires = this.wires()
|
|
88
|
+
.filter(({ wire }) => wire && (removed.has(wire.from.node) || removed.has(wire.to.node)))
|
|
89
|
+
.map(({ index, wire }) => ({ index, label: wireLabel(wire) }));
|
|
90
|
+
this.removeWires(wires.map((wire) => wire.index));
|
|
91
|
+
this.document.nodes = this.document.nodes.filter((candidate) => !removed.has(candidate.id));
|
|
92
|
+
this.forgetInAnnotations(removed);
|
|
93
|
+
if (this.document.editor && JSON.stringify(this.document.editor).includes(JSON.stringify(id))) {
|
|
94
|
+
this.warnings.push(`The editor section still names ${id}; republish or unpublish its parameters`);
|
|
95
|
+
}
|
|
96
|
+
return { removed: [...removed], wires: wires.map((wire) => wire.label) };
|
|
97
|
+
}
|
|
98
|
+
// -------------------------------------------------------------------------
|
|
99
|
+
// Wires
|
|
100
|
+
/**
|
|
101
|
+
* Wire `from` (`node.output`) to `to` (`node.input`). A port name may be
|
|
102
|
+
* left off when the node has exactly one port on that side.
|
|
103
|
+
*
|
|
104
|
+
* The two nodes may be in different subnets: the wire is then routed
|
|
105
|
+
* through the subnets' `Output` and `Input` nodes, which are created (or
|
|
106
|
+
* reused) as needed, so no wire ever crosses a boundary directly.
|
|
107
|
+
*
|
|
108
|
+
* A node wired for the first time is moved next to its new neighbour, so a
|
|
109
|
+
* node created by `node add` and then connected ends up where a person would
|
|
110
|
+
* have put it. Pass `layout: false` to leave positions alone.
|
|
111
|
+
*/
|
|
112
|
+
connect(from, to, options = {}) {
|
|
113
|
+
const source = this.resolveEndpoint(from, "output");
|
|
114
|
+
const target = this.resolveEndpoint(to, "input");
|
|
115
|
+
const targetNode = this.requireNode(target.endpoint.node);
|
|
116
|
+
if (!typesConnect(source.info, target.info)) {
|
|
117
|
+
throw new GraphEditError(`${from} (${source.info?.type}) cannot feed ${to} (${target.info?.type})`);
|
|
118
|
+
}
|
|
119
|
+
// Across a subnet boundary, the wire goes through the subnet's ports.
|
|
120
|
+
const routed = this.routeAcross(source.endpoint, parentOf(targetNode));
|
|
121
|
+
const sourceNode = this.requireNode(routed.node);
|
|
122
|
+
const wires = this.wires();
|
|
123
|
+
const wire = { from: routed, to: target.endpoint };
|
|
124
|
+
if (wires.some(({ wire: other }) => other && sameEndpoint(other.from, wire.from) && sameEndpoint(other.to, wire.to))) {
|
|
125
|
+
this.warnings.push(`${wireLabel(wire)} was already connected`);
|
|
126
|
+
return { wire: wireLabel(wire) };
|
|
127
|
+
}
|
|
128
|
+
const sourceWasFree = routed === source.endpoint && !this.isWired(routed.node);
|
|
129
|
+
const targetWasFree = !this.isWired(target.endpoint.node);
|
|
130
|
+
let replaced;
|
|
131
|
+
const existing = target.info?.variadic ? undefined : wires.find(({ wire: other }) => other && sameEndpoint(other.to, wire.to));
|
|
132
|
+
if (existing?.wire) {
|
|
133
|
+
replaced = wireLabel(existing.wire);
|
|
134
|
+
this.document.connections[existing.index] = writeWire(wire, wireStyleOf(this.document));
|
|
135
|
+
this.warnings.push(`${target.endpoint.node}.${target.endpoint.port} was fed by ${existing.wire.from.node}.${existing.wire.from.port}; replaced`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
this.appendWire(wire);
|
|
139
|
+
}
|
|
140
|
+
let moved;
|
|
141
|
+
if (options.layout !== false) {
|
|
142
|
+
if (targetWasFree && !sourceWasFree)
|
|
143
|
+
moved = this.relayout(targetNode);
|
|
144
|
+
else if (sourceWasFree && !targetWasFree)
|
|
145
|
+
moved = this.relayout(sourceNode);
|
|
146
|
+
else if (sourceWasFree && targetWasFree)
|
|
147
|
+
moved = this.relayout(targetNode);
|
|
148
|
+
}
|
|
149
|
+
return { wire: wireLabel(wire), ...(replaced ? { replaced } : {}), ...(moved ? { moved } : {}) };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The endpoint on `level`'s canvas that carries `source`: the source itself
|
|
153
|
+
* when it is already there, otherwise a subnet port reached by passing out
|
|
154
|
+
* through `Output` nodes to the common ancestor and in through `Input` nodes
|
|
155
|
+
* to `level`. Existing boundaries carrying the same value are reused.
|
|
156
|
+
*/
|
|
157
|
+
routeAcross(source, level) {
|
|
158
|
+
const start = parentOf(this.requireNode(source.node));
|
|
159
|
+
if (start === level)
|
|
160
|
+
return source;
|
|
161
|
+
const down = [...this.ancestorsOf(level), ...(level ? [level] : [])];
|
|
162
|
+
const up = [...this.ancestorsOf(start), ...(start ? [start] : [])];
|
|
163
|
+
let common = 0;
|
|
164
|
+
while (common < down.length && common < up.length && down[common] === up[common])
|
|
165
|
+
common += 1;
|
|
166
|
+
let current = source;
|
|
167
|
+
const touched = new Map();
|
|
168
|
+
for (const subnetId of up.slice(common).reverse()) {
|
|
169
|
+
const subnet = this.requireNode(subnetId);
|
|
170
|
+
const created = touched.get(subnetId) ?? [];
|
|
171
|
+
const { node: output, isNew } = this.outputFor(subnet, current, created);
|
|
172
|
+
if (isNew)
|
|
173
|
+
this.appendWire({ from: current, to: { node: output.id, port: "input", index: 0 } });
|
|
174
|
+
touched.set(subnetId, created);
|
|
175
|
+
current = this.subnetPort(subnet, "output", output);
|
|
176
|
+
}
|
|
177
|
+
let lastInput;
|
|
178
|
+
for (const subnetId of down.slice(common)) {
|
|
179
|
+
const subnet = this.requireNode(subnetId);
|
|
180
|
+
const created = touched.get(subnetId) ?? [];
|
|
181
|
+
const existing = this.inputCarrying(subnet, current, new Set());
|
|
182
|
+
lastInput = existing ?? this.createBoundary(subnet, "input", current, created);
|
|
183
|
+
if (!existing)
|
|
184
|
+
this.appendWire({ from: current, to: this.subnetPort(subnet, "input", lastInput) });
|
|
185
|
+
touched.set(subnetId, created);
|
|
186
|
+
current = { node: lastInput.id, port: "output", index: 0 };
|
|
187
|
+
}
|
|
188
|
+
for (const [subnetId, created] of touched) {
|
|
189
|
+
this.nameBoundaries(this.requireNode(subnetId), created);
|
|
190
|
+
for (const node of created)
|
|
191
|
+
setPosition(node, this.placeFor(node));
|
|
192
|
+
}
|
|
193
|
+
// Naming may have given the last Input a better id; the object is the same.
|
|
194
|
+
if (lastInput)
|
|
195
|
+
current = { node: lastInput.id, port: "output", index: 0 };
|
|
196
|
+
return current;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Remove wires. `disconnect a.out b.in` removes that wire; `disconnect b.in`
|
|
200
|
+
* removes whatever feeds that input; `disconnect a.out` removes everything
|
|
201
|
+
* that output feeds.
|
|
202
|
+
*/
|
|
203
|
+
disconnect(first, second) {
|
|
204
|
+
let matches;
|
|
205
|
+
if (second !== undefined) {
|
|
206
|
+
const source = this.resolveEndpoint(first, "output").endpoint;
|
|
207
|
+
const target = this.resolveEndpoint(second, "input").endpoint;
|
|
208
|
+
matches = (wire) => sameEndpoint(wire.from, source) && sameEndpoint(wire.to, target);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const { node, port } = splitEndpoint(first);
|
|
212
|
+
this.requireNode(node);
|
|
213
|
+
matches = (wire) => (wire.to.node === node && (!port || wire.to.port === port)) ||
|
|
214
|
+
(wire.from.node === node && (!port || wire.from.port === port) && !!port);
|
|
215
|
+
}
|
|
216
|
+
const found = this.wires().filter(({ wire }) => wire && matches(wire));
|
|
217
|
+
if (!found.length)
|
|
218
|
+
throw new GraphEditError(`No wire matches ${second === undefined ? first : `${first} -> ${second}`}`);
|
|
219
|
+
this.removeWires(found.map(({ index }) => index));
|
|
220
|
+
return { removed: found.map(({ wire }) => wireLabel(wire)) };
|
|
221
|
+
}
|
|
222
|
+
// -------------------------------------------------------------------------
|
|
223
|
+
// Parameters
|
|
224
|
+
/** Change `node.param`: its value, expression or keyframes. */
|
|
225
|
+
setParameter(target, change) {
|
|
226
|
+
const { node: id, port: name } = splitEndpoint(target);
|
|
227
|
+
if (!name)
|
|
228
|
+
throw new GraphEditError(`Name a parameter: ${target}.<param>`);
|
|
229
|
+
const node = this.requireNode(id);
|
|
230
|
+
const result = this.setParameterOn(node, name, change);
|
|
231
|
+
if (result.field === "inputs" && this.wires().some(({ wire }) => wire && wire.to.node === id && wire.to.port === name)) {
|
|
232
|
+
this.warnings.push(`${id}.${name} is wired; the stored value is used only when the wire is removed`);
|
|
233
|
+
}
|
|
234
|
+
// `null` rather than `undefined`, so "nothing stored, the default applies" survives JSON.
|
|
235
|
+
return { node: id, param: name, field: result.field, before: result.before ?? null, after: result.after ?? null };
|
|
236
|
+
}
|
|
237
|
+
// -------------------------------------------------------------------------
|
|
238
|
+
// Subnets
|
|
239
|
+
/**
|
|
240
|
+
* Group sibling nodes into a new subnet. Wires that now cross its boundary
|
|
241
|
+
* are routed through new `Input` and `Output` nodes, named after the port
|
|
242
|
+
* they carry, so the subnet's ports read as what flows through them.
|
|
243
|
+
*/
|
|
244
|
+
createSubnet(input) {
|
|
245
|
+
const members = this.requireSiblings(input.nodes);
|
|
246
|
+
const parent = parentOf(members[0]);
|
|
247
|
+
const id = input.id ?? this.uniqueId(input.label ? slug(input.label) : "subnet");
|
|
248
|
+
assertValidId(id);
|
|
249
|
+
if (this.findNode(id))
|
|
250
|
+
throw new GraphEditError(`A node with id "${id}" already exists`);
|
|
251
|
+
// The subnet takes the place of the first member in the flow, so the
|
|
252
|
+
// parent canvas reads the same with the group closed as it did open.
|
|
253
|
+
const step = this.stepIn(parent);
|
|
254
|
+
const first = members.reduce((best, node) => (along(positionOf(node), step) < along(positionOf(best), step) ? node : best));
|
|
255
|
+
const subnet = { id, module: SUBNET, position: [0, 0], source: "stdlib" };
|
|
256
|
+
if (input.label)
|
|
257
|
+
subnet.comment = input.label;
|
|
258
|
+
if (parent)
|
|
259
|
+
subnet.parent = parent;
|
|
260
|
+
setPosition(subnet, positionOf(first));
|
|
261
|
+
const at = Math.min(...members.map((node) => this.document.nodes.indexOf(node)));
|
|
262
|
+
this.document.nodes.splice(at, 0, subnet);
|
|
263
|
+
const created = this.moveIn(new Set(members.map((node) => node.id)), subnet);
|
|
264
|
+
return {
|
|
265
|
+
subnet: this.summary(subnet),
|
|
266
|
+
inputs: created.filter((node) => moduleOf(node) === INPUT).map((node) => node.id),
|
|
267
|
+
outputs: created.filter((node) => moduleOf(node) === OUTPUT).map((node) => node.id),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/** The inverse of `createSubnet`: lift the children out and remove the subnet. */
|
|
271
|
+
dissolveSubnet(id) {
|
|
272
|
+
const subnet = this.requireNode(id);
|
|
273
|
+
if (moduleOf(subnet) !== SUBNET)
|
|
274
|
+
throw new GraphEditError(`${id} is not a subnet`);
|
|
275
|
+
const members = this.childrenOf(id).filter((node) => !isBoundary(node));
|
|
276
|
+
if (members.length)
|
|
277
|
+
this.moveOut(new Set(members.map((node) => node.id)), subnet);
|
|
278
|
+
for (const leftover of this.childrenOf(id)) {
|
|
279
|
+
this.warnings.push(`${leftover.id} carried nothing after the dissolve and was removed`);
|
|
280
|
+
}
|
|
281
|
+
this.removeNode(id, { recursive: true });
|
|
282
|
+
return { lifted: members.map((node) => node.id) };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Move sibling nodes to another subnet (`null` for the top level), one level
|
|
286
|
+
* at a time — out to the common ancestor, then in — rewiring through each
|
|
287
|
+
* boundary on the way.
|
|
288
|
+
*/
|
|
289
|
+
moveNodes(ids, to) {
|
|
290
|
+
const members = this.requireSiblings(ids);
|
|
291
|
+
const target = to ? this.requireNode(to) : null;
|
|
292
|
+
if (target && moduleOf(target) !== SUBNET)
|
|
293
|
+
throw new GraphEditError(`${to} is not a subnet`);
|
|
294
|
+
const memberIds = new Set(members.map((node) => node.id));
|
|
295
|
+
if (target && (memberIds.has(target.id) || this.ancestorsOf(target.id).some((id) => memberIds.has(id)))) {
|
|
296
|
+
throw new GraphEditError(`Cannot move ${target.id} into itself`);
|
|
297
|
+
}
|
|
298
|
+
const destination = target?.id ?? null;
|
|
299
|
+
const targetPath = [...this.ancestorsOf(destination), ...(destination ? [destination] : [])];
|
|
300
|
+
let current = parentOf(members[0]);
|
|
301
|
+
while (current !== null && !targetPath.includes(current)) {
|
|
302
|
+
const container = this.requireNode(current);
|
|
303
|
+
this.moveOut(memberIds, container);
|
|
304
|
+
current = parentOf(container);
|
|
305
|
+
}
|
|
306
|
+
while (current !== destination) {
|
|
307
|
+
// `indexOf(null)` is -1, so from the top level this is the first step down.
|
|
308
|
+
const next = targetPath[targetPath.indexOf(current) + 1];
|
|
309
|
+
this.moveIn(memberIds, this.requireNode(next));
|
|
310
|
+
current = next;
|
|
311
|
+
}
|
|
312
|
+
// Keep each moved node where it was unless it now sits on top of another.
|
|
313
|
+
const placed = [];
|
|
314
|
+
for (const node of members) {
|
|
315
|
+
const others = this.childrenOf(destination).filter((other) => !memberIds.has(other.id)).map(positionOf);
|
|
316
|
+
const moved = isOccupied(others, positionOf(node)) ? this.relayout(node) : undefined;
|
|
317
|
+
if (moved)
|
|
318
|
+
placed.push(moved);
|
|
319
|
+
}
|
|
320
|
+
return { moved: [...memberIds], to: destination, placed };
|
|
321
|
+
}
|
|
322
|
+
// -------------------------------------------------------------------------
|
|
323
|
+
// Internals: boundary rewiring
|
|
324
|
+
/**
|
|
325
|
+
* Move sibling nodes from the subnet's parent into the subnet.
|
|
326
|
+
*
|
|
327
|
+
* A wire from a member to the subnet's own input collapses (the member is
|
|
328
|
+
* now inside, so it feeds that input's consumers directly), and a wire from
|
|
329
|
+
* outside to a member is routed through an `Input` — reusing one that
|
|
330
|
+
* already carries the same source. The mirror holds for outputs. Wires are
|
|
331
|
+
* rewritten in place where possible, which is what lets `moveOut` restore
|
|
332
|
+
* the original file exactly.
|
|
333
|
+
*/
|
|
334
|
+
moveIn(members, subnet) {
|
|
335
|
+
for (const id of members)
|
|
336
|
+
setOrderedKey(this.requireNode(id), "parent", subnet.id, NODE_KEY_ORDER);
|
|
337
|
+
const created = [];
|
|
338
|
+
const removed = new Set();
|
|
339
|
+
const total = this.connections().length;
|
|
340
|
+
for (let index = 0; index < total; index += 1) {
|
|
341
|
+
const wire = readWire(this.connections()[index]);
|
|
342
|
+
if (!wire || removed.has(index))
|
|
343
|
+
continue;
|
|
344
|
+
const fromMember = members.has(wire.from.node);
|
|
345
|
+
const toMember = members.has(wire.to.node);
|
|
346
|
+
if (fromMember === toMember)
|
|
347
|
+
continue;
|
|
348
|
+
if (fromMember && wire.to.node === subnet.id) {
|
|
349
|
+
// member -> subnet.input_k: feed that input's consumers directly.
|
|
350
|
+
const k = portNumber(wire.to.port);
|
|
351
|
+
const boundary = this.boundaryNode(subnet, "input", k);
|
|
352
|
+
if (boundary) {
|
|
353
|
+
for (const consumer of this.wires().filter(({ wire: w }) => w?.from.node === boundary.id)) {
|
|
354
|
+
this.replaceWire(consumer.index, { from: wire.from, to: consumer.wire.to });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
removed.add(index);
|
|
358
|
+
this.removeBoundaryNode(subnet, "input", k, removed);
|
|
359
|
+
}
|
|
360
|
+
else if (fromMember) {
|
|
361
|
+
// member -> outside: out through an Output.
|
|
362
|
+
const { node: output, isNew } = this.outputFor(subnet, wire.from, created);
|
|
363
|
+
this.replaceWire(index, { from: this.subnetPort(subnet, "output", output), to: wire.to });
|
|
364
|
+
if (isNew)
|
|
365
|
+
this.appendWire({ from: wire.from, to: { node: output.id, port: "input", index: 0 } });
|
|
366
|
+
}
|
|
367
|
+
else if (wire.from.node === subnet.id) {
|
|
368
|
+
// subnet.output_k -> member: the member reads the Output's source directly.
|
|
369
|
+
const k = portNumber(wire.from.port);
|
|
370
|
+
const boundary = this.boundaryNode(subnet, "output", k);
|
|
371
|
+
const feed = boundary && this.wires().find(({ wire: w }) => w?.to.node === boundary.id);
|
|
372
|
+
if (feed?.wire)
|
|
373
|
+
this.replaceWire(index, { from: feed.wire.from, to: wire.to });
|
|
374
|
+
else
|
|
375
|
+
removed.add(index);
|
|
376
|
+
const stillUsed = this.wires().some(({ index: i, wire: w }) => !removed.has(i) && w?.from.node === subnet.id && w.from.port === wire.from.port && i !== index);
|
|
377
|
+
if (!stillUsed && boundary) {
|
|
378
|
+
if (feed)
|
|
379
|
+
removed.add(feed.index);
|
|
380
|
+
this.removeBoundaryNode(subnet, "output", k, removed);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
// outside -> member: in through an Input.
|
|
385
|
+
const existing = this.inputCarrying(subnet, wire.from, removed);
|
|
386
|
+
if (existing) {
|
|
387
|
+
removed.add(index);
|
|
388
|
+
this.appendWire({ from: { node: existing.id, port: "output", index: 0 }, to: wire.to });
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
const input = this.createBoundary(subnet, "input", wire.from, created);
|
|
392
|
+
this.replaceWire(index, { from: wire.from, to: this.subnetPort(subnet, "input", input) });
|
|
393
|
+
this.appendWire({ from: { node: input.id, port: "output", index: 0 }, to: wire.to });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
this.removeWires([...removed]);
|
|
398
|
+
this.nameBoundaries(subnet, created);
|
|
399
|
+
for (const node of created)
|
|
400
|
+
setPosition(node, this.placeFor(node));
|
|
401
|
+
return created;
|
|
402
|
+
}
|
|
403
|
+
/** Move nodes out of a subnet into its parent. The mirror of `moveIn`. */
|
|
404
|
+
moveOut(members, subnet) {
|
|
405
|
+
const created = [];
|
|
406
|
+
const removed = new Set();
|
|
407
|
+
const total = this.connections().length;
|
|
408
|
+
for (let index = 0; index < total; index += 1) {
|
|
409
|
+
const wire = readWire(this.connections()[index]);
|
|
410
|
+
if (!wire || removed.has(index))
|
|
411
|
+
continue;
|
|
412
|
+
const fromMember = members.has(wire.from.node);
|
|
413
|
+
const toMember = members.has(wire.to.node);
|
|
414
|
+
if (fromMember === toMember)
|
|
415
|
+
continue;
|
|
416
|
+
const other = this.findNode(fromMember ? wire.to.node : wire.from.node);
|
|
417
|
+
if (fromMember && other && moduleOf(other) === OUTPUT && parentOf(other) === subnet.id) {
|
|
418
|
+
// member -> Output_k: whatever read subnet.output_k reads the member.
|
|
419
|
+
const k = integer(other.props?.outputIndex);
|
|
420
|
+
for (const consumer of this.wires().filter(({ wire: w }) => w?.from.node === subnet.id && w.from.port === `output_${k}`)) {
|
|
421
|
+
this.replaceWire(consumer.index, { from: wire.from, to: consumer.wire.to });
|
|
422
|
+
}
|
|
423
|
+
removed.add(index);
|
|
424
|
+
this.removeBoundaryNode(subnet, "output", k, removed);
|
|
425
|
+
}
|
|
426
|
+
else if (fromMember) {
|
|
427
|
+
// member -> a node staying inside: in through an Input.
|
|
428
|
+
const existing = this.inputCarrying(subnet, wire.from, removed);
|
|
429
|
+
const input = existing ?? this.createBoundary(subnet, "input", wire.from, created);
|
|
430
|
+
this.replaceWire(index, { from: { node: input.id, port: "output", index: 0 }, to: wire.to });
|
|
431
|
+
if (!existing)
|
|
432
|
+
this.appendWire({ from: wire.from, to: this.subnetPort(subnet, "input", input) });
|
|
433
|
+
}
|
|
434
|
+
else if (other && moduleOf(other) === INPUT && parentOf(other) === subnet.id) {
|
|
435
|
+
// Input_k -> member: the member reads what feeds subnet.input_k.
|
|
436
|
+
const k = integer(other.props?.inputIndex);
|
|
437
|
+
const feed = this.wires().find(({ index: i, wire: w }) => !removed.has(i) && w?.to.node === subnet.id && w.to.port === `input_${k}`);
|
|
438
|
+
const otherConsumers = this.wires().some(({ index: i, wire: w }) => i !== index && !removed.has(i) && w?.from.node === other.id);
|
|
439
|
+
if (!feed?.wire) {
|
|
440
|
+
removed.add(index);
|
|
441
|
+
this.warnings.push(`${wire.to.node}.${wire.to.port} was fed by an unwired subnet input; it is now unwired`);
|
|
442
|
+
}
|
|
443
|
+
else if (!otherConsumers) {
|
|
444
|
+
this.replaceWire(feed.index, { from: feed.wire.from, to: wire.to });
|
|
445
|
+
removed.add(index);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
this.replaceWire(index, { from: feed.wire.from, to: wire.to });
|
|
449
|
+
}
|
|
450
|
+
if (!otherConsumers)
|
|
451
|
+
this.removeBoundaryNode(subnet, "input", k, removed);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
// a node staying inside -> member: out through an Output.
|
|
455
|
+
const { node: output, isNew } = this.outputFor(subnet, wire.from, created);
|
|
456
|
+
this.replaceWire(index, { from: this.subnetPort(subnet, "output", output), to: wire.to });
|
|
457
|
+
if (isNew)
|
|
458
|
+
this.appendWire({ from: wire.from, to: { node: output.id, port: "input", index: 0 } });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
this.removeWires([...removed]);
|
|
462
|
+
const parent = parentOf(subnet);
|
|
463
|
+
for (const id of members) {
|
|
464
|
+
const node = this.requireNode(id);
|
|
465
|
+
if (parent)
|
|
466
|
+
setOrderedKey(node, "parent", parent, NODE_KEY_ORDER);
|
|
467
|
+
else
|
|
468
|
+
delete node.parent;
|
|
469
|
+
}
|
|
470
|
+
this.nameBoundaries(subnet, created);
|
|
471
|
+
for (const node of created)
|
|
472
|
+
setPosition(node, this.placeFor(node));
|
|
473
|
+
return created;
|
|
474
|
+
}
|
|
475
|
+
/** An `Output` in the subnet already fed by `source`, or a new one. */
|
|
476
|
+
outputFor(subnet, source, created) {
|
|
477
|
+
const existing = this.childrenOf(subnet.id).find((node) => moduleOf(node) === OUTPUT &&
|
|
478
|
+
this.wires().some(({ wire }) => wire?.to.node === node.id && sameEndpoint(wire.from, source)));
|
|
479
|
+
return existing ? { node: existing, isNew: false } : { node: this.createBoundary(subnet, "output", source, created), isNew: true };
|
|
480
|
+
}
|
|
481
|
+
/** An `Input` in the subnet whose outside feed is `source`. */
|
|
482
|
+
inputCarrying(subnet, source, removed) {
|
|
483
|
+
const feed = this.wires().find(({ index, wire }) => !removed.has(index) && wire?.to.node === subnet.id && sameEndpoint(wire.from, source));
|
|
484
|
+
return feed ? this.boundaryNode(subnet, "input", portNumber(feed.wire.to.port)) : undefined;
|
|
485
|
+
}
|
|
486
|
+
createBoundary(subnet, kind, carries, created) {
|
|
487
|
+
const ports = subnetPorts(this.childrenOf(subnet.id));
|
|
488
|
+
const index = (kind === "input" ? ports.inputs : ports.outputs).length;
|
|
489
|
+
const label = this.labelOf(carries);
|
|
490
|
+
const type = this.portInfo(carries.node, carries.port, "output")?.type ?? "any";
|
|
491
|
+
const id = this.uniqueId(`${subnet.id}-${kind === "input" ? "in" : "out"}-${slug(label)}`);
|
|
492
|
+
const node = {
|
|
493
|
+
id,
|
|
494
|
+
module: kind === "input" ? INPUT : OUTPUT,
|
|
495
|
+
position: [0, 0],
|
|
496
|
+
source: "stdlib",
|
|
497
|
+
comment: label,
|
|
498
|
+
parent: subnet.id,
|
|
499
|
+
props: kind === "input"
|
|
500
|
+
// The name stays empty and the label lives in `comment`, as in every
|
|
501
|
+
// hand-built subnet: Studio names a subnet port after a non-empty
|
|
502
|
+
// name while the runtime always uses `input_<n>`, so a name would make
|
|
503
|
+
// the two hosts disagree about the port and drop the wire in Studio.
|
|
504
|
+
? { inputIndex: index, inputName: "", dataType: type }
|
|
505
|
+
: { outputIndex: index, outputName: "", dataType: type },
|
|
506
|
+
};
|
|
507
|
+
this.insertNode(node, subnet.id);
|
|
508
|
+
created.push(node);
|
|
509
|
+
return node;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Name new boundary nodes after what they carry, readably. A port name is
|
|
513
|
+
* the natural label (`moment`, `canvas`), but not when it says nothing
|
|
514
|
+
* (`value`, `output`) or when two ports of the subnet would share it — three
|
|
515
|
+
* `mask` outputs read as one. Then the producing node's id is the label:
|
|
516
|
+
* `masks-out-cloud-mask` beside `masks-out-primary-lobe`.
|
|
517
|
+
*/
|
|
518
|
+
nameBoundaries(subnet, created) {
|
|
519
|
+
const carried = (node) => {
|
|
520
|
+
const wire = moduleOf(node) === INPUT
|
|
521
|
+
? this.wires().find(({ wire: w }) => w?.to.node === subnet.id && w.to.port === `input_${integer(node.props?.inputIndex)}`)?.wire
|
|
522
|
+
: this.wires().find(({ wire: w }) => w?.to.node === node.id)?.wire;
|
|
523
|
+
return wire?.from;
|
|
524
|
+
};
|
|
525
|
+
// Decided for all of them first, so renaming one cannot make its twin look unique.
|
|
526
|
+
const relabel = created.flatMap((node) => {
|
|
527
|
+
const label = String(node.comment ?? "");
|
|
528
|
+
const sameSide = this.childrenOf(subnet.id).filter((other) => moduleOf(other) === moduleOf(node));
|
|
529
|
+
const shared = sameSide.filter((other) => other.comment === label).length > 1;
|
|
530
|
+
const source = carried(node);
|
|
531
|
+
return source && (shared || GENERIC_PORTS.has(label)) ? [{ node, label: source.node }] : [];
|
|
532
|
+
});
|
|
533
|
+
for (const { node, label } of relabel)
|
|
534
|
+
this.relabelBoundary(subnet, node, label);
|
|
535
|
+
}
|
|
536
|
+
relabelBoundary(subnet, node, label) {
|
|
537
|
+
const kind = moduleOf(node) === INPUT ? "in" : "out";
|
|
538
|
+
const id = this.uniqueId(`${subnet.id}-${kind}-${slug(label)}`);
|
|
539
|
+
const previous = node.id;
|
|
540
|
+
node.id = id;
|
|
541
|
+
node.comment = label;
|
|
542
|
+
for (const { index, wire } of this.wires()) {
|
|
543
|
+
if (!wire || (wire.from.node !== previous && wire.to.node !== previous))
|
|
544
|
+
continue;
|
|
545
|
+
const rename = (end) => (end.node === previous ? { ...end, node: id } : end);
|
|
546
|
+
this.replaceWire(index, { from: rename(wire.from), to: rename(wire.to) });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/** What a value is called: its port name, or for a subnet's own port, the name of the boundary behind it. */
|
|
550
|
+
labelOf(endpoint) {
|
|
551
|
+
const node = this.findNode(endpoint.node);
|
|
552
|
+
const port = /^(input|output)_(\d+)$/.exec(endpoint.port);
|
|
553
|
+
if (node && moduleOf(node) === SUBNET && port) {
|
|
554
|
+
const boundary = this.boundaryNode(node, port[1], Number(port[2]));
|
|
555
|
+
const name = boundary?.comment || boundary?.props?.[port[1] === "input" ? "inputName" : "outputName"];
|
|
556
|
+
if (typeof name === "string" && name)
|
|
557
|
+
return name;
|
|
558
|
+
}
|
|
559
|
+
return endpoint.port || endpoint.node;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Remove boundary node k and keep the subnet's ports contiguous: the ports
|
|
563
|
+
* above it shift down one, and so do the wires that use them. Wires to the
|
|
564
|
+
* removed port itself are marked in `removed` for the caller to drop.
|
|
565
|
+
*/
|
|
566
|
+
removeBoundaryNode(subnet, kind, k, removed) {
|
|
567
|
+
const node = this.boundaryNode(subnet, kind, k);
|
|
568
|
+
if (!node)
|
|
569
|
+
return;
|
|
570
|
+
const indexProp = kind === "input" ? "inputIndex" : "outputIndex";
|
|
571
|
+
const port = `${kind}_${k}`;
|
|
572
|
+
for (const { index, wire } of this.wires()) {
|
|
573
|
+
if (!wire)
|
|
574
|
+
continue;
|
|
575
|
+
const touchesPort = kind === "input"
|
|
576
|
+
? wire.to.node === subnet.id && wire.to.port === port
|
|
577
|
+
: wire.from.node === subnet.id && wire.from.port === port;
|
|
578
|
+
if (touchesPort || wire.from.node === node.id || wire.to.node === node.id)
|
|
579
|
+
removed.add(index);
|
|
580
|
+
}
|
|
581
|
+
this.document.nodes.splice(this.document.nodes.indexOf(node), 1);
|
|
582
|
+
for (const sibling of this.childrenOf(subnet.id)) {
|
|
583
|
+
if (moduleOf(sibling) !== (kind === "input" ? INPUT : OUTPUT))
|
|
584
|
+
continue;
|
|
585
|
+
const index = integer(sibling.props?.[indexProp]);
|
|
586
|
+
if (index > k)
|
|
587
|
+
sibling.props[indexProp] = index - 1;
|
|
588
|
+
}
|
|
589
|
+
for (const { index, wire } of this.wires()) {
|
|
590
|
+
if (!wire || removed.has(index))
|
|
591
|
+
continue;
|
|
592
|
+
const end = kind === "input" ? wire.to : wire.from;
|
|
593
|
+
if (end.node !== subnet.id)
|
|
594
|
+
continue;
|
|
595
|
+
const n = portNumber(end.port);
|
|
596
|
+
if (n <= k)
|
|
597
|
+
continue;
|
|
598
|
+
const shifted = { node: subnet.id, port: `${kind}_${n - 1}`, index: n - 1 };
|
|
599
|
+
this.replaceWire(index, kind === "input" ? { from: wire.from, to: shifted } : { from: shifted, to: wire.to });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/** `rm` of a boundary node: the subnet loses that port. */
|
|
603
|
+
removeBoundary(subnet, kind, k) {
|
|
604
|
+
const removed = new Set();
|
|
605
|
+
const before = this.wires();
|
|
606
|
+
this.removeBoundaryNode(subnet, kind, k, removed);
|
|
607
|
+
const labels = before.filter(({ index }) => removed.has(index)).map(({ wire }) => wireLabel(wire));
|
|
608
|
+
this.removeWires([...removed]);
|
|
609
|
+
this.warnings.push(`${subnet.id} lost its ${kind}_${k} port; higher ports were renumbered`);
|
|
610
|
+
return labels;
|
|
611
|
+
}
|
|
612
|
+
boundaryIndex(node) {
|
|
613
|
+
if (moduleOf(node) === INPUT)
|
|
614
|
+
return { kind: "input", index: integer(node.props?.inputIndex) };
|
|
615
|
+
if (moduleOf(node) === OUTPUT)
|
|
616
|
+
return { kind: "output", index: integer(node.props?.outputIndex) };
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
boundaryNode(subnet, kind, k) {
|
|
620
|
+
const module = kind === "input" ? INPUT : OUTPUT;
|
|
621
|
+
const indexProp = kind === "input" ? "inputIndex" : "outputIndex";
|
|
622
|
+
return this.childrenOf(subnet.id).find((node) => moduleOf(node) === module && integer(node.props?.[indexProp]) === k);
|
|
623
|
+
}
|
|
624
|
+
subnetPort(subnet, kind, boundary) {
|
|
625
|
+
const k = integer(boundary.props?.[kind === "input" ? "inputIndex" : "outputIndex"]);
|
|
626
|
+
return { node: subnet.id, port: `${kind}_${k}`, index: k };
|
|
627
|
+
}
|
|
628
|
+
// -------------------------------------------------------------------------
|
|
629
|
+
// Internals: lookup
|
|
630
|
+
findNode(id) {
|
|
631
|
+
return this.document.nodes.find((node) => node.id === id);
|
|
632
|
+
}
|
|
633
|
+
requireNode(id) {
|
|
634
|
+
const node = this.findNode(id);
|
|
635
|
+
if (node)
|
|
636
|
+
return node;
|
|
637
|
+
const near = this.document.nodes.map((candidate) => candidate.id).filter((candidate) => candidate.includes(id) || id.includes(candidate)).slice(0, 5);
|
|
638
|
+
throw new GraphEditError(`No node "${id}"${near.length ? `; did you mean ${near.join(", ")}?` : ""}`);
|
|
639
|
+
}
|
|
640
|
+
childrenOf(parent) {
|
|
641
|
+
return this.document.nodes.filter((node) => parentOf(node) === parent);
|
|
642
|
+
}
|
|
643
|
+
descendantsOf(id) {
|
|
644
|
+
const result = [];
|
|
645
|
+
const queue = [id];
|
|
646
|
+
while (queue.length) {
|
|
647
|
+
const current = queue.shift();
|
|
648
|
+
for (const child of this.childrenOf(current)) {
|
|
649
|
+
result.push(child);
|
|
650
|
+
queue.push(child.id);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return result;
|
|
654
|
+
}
|
|
655
|
+
ancestorsOf(id) {
|
|
656
|
+
const chain = [];
|
|
657
|
+
let current = id ? parentOf(this.requireNode(id)) : null;
|
|
658
|
+
while (current) {
|
|
659
|
+
chain.unshift(current);
|
|
660
|
+
current = parentOf(this.requireNode(current));
|
|
661
|
+
}
|
|
662
|
+
return chain;
|
|
663
|
+
}
|
|
664
|
+
requireContainer(id) {
|
|
665
|
+
const node = this.requireNode(id);
|
|
666
|
+
const definition = this.options.definitionOf(moduleOf(node));
|
|
667
|
+
if (moduleOf(node) !== SUBNET && !definition?.container)
|
|
668
|
+
throw new GraphEditError(`${id} is not a subnet`);
|
|
669
|
+
return node;
|
|
670
|
+
}
|
|
671
|
+
requireSiblings(ids) {
|
|
672
|
+
if (!ids.length)
|
|
673
|
+
throw new GraphEditError("Name at least one node");
|
|
674
|
+
const nodes = [...new Set(ids)].map((id) => this.requireNode(id));
|
|
675
|
+
const parent = parentOf(nodes[0]);
|
|
676
|
+
const stray = nodes.find((node) => parentOf(node) !== parent);
|
|
677
|
+
if (stray)
|
|
678
|
+
throw new GraphEditError(`${stray.id} is not in the same subnet as ${nodes[0].id}; move nodes from one subnet at a time`);
|
|
679
|
+
const boundary = nodes.find(isBoundary);
|
|
680
|
+
if (boundary && parent)
|
|
681
|
+
throw new GraphEditError(`${boundary.id} is ${parent}'s ${moduleOf(boundary) === INPUT ? "input" : "output"}; it stays with its subnet`);
|
|
682
|
+
return nodes;
|
|
683
|
+
}
|
|
684
|
+
/** The ports a node has, or `null` when they are not statically known. */
|
|
685
|
+
portsOf(node) {
|
|
686
|
+
if (moduleOf(node) === SUBNET)
|
|
687
|
+
return subnetPorts(this.childrenOf(node.id));
|
|
688
|
+
const definition = this.options.definitionOf(moduleOf(node));
|
|
689
|
+
return definition ? portsFromDefinition(definition) : null;
|
|
690
|
+
}
|
|
691
|
+
portInfo(nodeId, port, side) {
|
|
692
|
+
const node = this.findNode(nodeId);
|
|
693
|
+
const ports = node && this.portsOf(node);
|
|
694
|
+
return ports ? (side === "input" ? ports.inputs : ports.outputs).find((info) => info.name === port) : undefined;
|
|
695
|
+
}
|
|
696
|
+
/** `node.port` to a checked endpoint. A missing port name is filled in when only one would fit. */
|
|
697
|
+
resolveEndpoint(text, side) {
|
|
698
|
+
const { node: id, port } = splitEndpoint(text);
|
|
699
|
+
const node = this.requireNode(id);
|
|
700
|
+
const ports = this.portsOf(node);
|
|
701
|
+
if (!ports) {
|
|
702
|
+
if (!port)
|
|
703
|
+
throw new GraphEditError(`${id} declares no static ports; name one: ${id}.<${side}>`);
|
|
704
|
+
this.warnings.push(`${id} (${moduleOf(node)}) has no static definition; ${port} is not checked`);
|
|
705
|
+
return { endpoint: { node: id, port, index: this.indexFromExistingWires(id, port, side) } };
|
|
706
|
+
}
|
|
707
|
+
const list = side === "input" ? ports.inputs : ports.outputs;
|
|
708
|
+
const info = port ? list.find((candidate) => candidate.name === port) : list.length === 1 ? list[0] : undefined;
|
|
709
|
+
if (!info) {
|
|
710
|
+
const names = list.map((candidate) => candidate.name).join(", ") || "none";
|
|
711
|
+
throw new GraphEditError(port
|
|
712
|
+
? `${id} has no ${side} "${port}"; ${side}s: ${names}`
|
|
713
|
+
: `${id} has several ${side}s; name one: ${names}`);
|
|
714
|
+
}
|
|
715
|
+
return { endpoint: { node: id, port: info.name, index: info.index }, info };
|
|
716
|
+
}
|
|
717
|
+
indexFromExistingWires(node, port, side) {
|
|
718
|
+
for (const { wire } of this.wires()) {
|
|
719
|
+
const end = wire && (side === "input" ? wire.to : wire.from);
|
|
720
|
+
if (end && end.node === node && end.port === port)
|
|
721
|
+
return end.index;
|
|
722
|
+
}
|
|
723
|
+
return 0;
|
|
724
|
+
}
|
|
725
|
+
isWired(id) {
|
|
726
|
+
return this.wires().some(({ wire }) => wire && (wire.from.node === id || wire.to.node === id));
|
|
727
|
+
}
|
|
728
|
+
setParameterOn(node, name, change) {
|
|
729
|
+
const slot = parameterSlot(this.options.definitionOf(moduleOf(node)), name);
|
|
730
|
+
if ("error" in slot)
|
|
731
|
+
throw new GraphEditError(`${node.id} ${slot.error}`);
|
|
732
|
+
if (!this.options.definitionOf(moduleOf(node))) {
|
|
733
|
+
this.warnings.push(`${node.id} (${moduleOf(node)}) has no static definition; ${name} is stored as a prop unchecked`);
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
return { field: slot.field, ...applyParameterChange(node, name, slot, change) };
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
throw new GraphEditError(error instanceof Error ? error.message : String(error));
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// -------------------------------------------------------------------------
|
|
743
|
+
// Internals: the connections array
|
|
744
|
+
connections() {
|
|
745
|
+
return (this.document.connections ??= []);
|
|
746
|
+
}
|
|
747
|
+
/** Every connection with its array index; unreadable ones have `wire: null` and are left alone. */
|
|
748
|
+
wires() {
|
|
749
|
+
return this.connections().map((raw, index) => ({ index, wire: readWire(raw) }));
|
|
750
|
+
}
|
|
751
|
+
appendWire(wire) {
|
|
752
|
+
const style = wireStyleOf(this.document);
|
|
753
|
+
if (!this.document.connections)
|
|
754
|
+
setOrderedKey(this.document, "connections", [], ["version", "metadata", "annotations", "nodes", "connections", "editor"]);
|
|
755
|
+
this.connections().push(writeWire(wire, style));
|
|
756
|
+
}
|
|
757
|
+
replaceWire(index, wire) {
|
|
758
|
+
this.connections()[index] = writeWire(wire, wireStyleOf(this.document));
|
|
759
|
+
}
|
|
760
|
+
removeWires(indices) {
|
|
761
|
+
const drop = new Set(indices);
|
|
762
|
+
if (!drop.size)
|
|
763
|
+
return;
|
|
764
|
+
this.document.connections = this.connections().filter((_, index) => !drop.has(index));
|
|
765
|
+
if (!this.document.connections.length)
|
|
766
|
+
delete this.document.connections;
|
|
767
|
+
}
|
|
768
|
+
// -------------------------------------------------------------------------
|
|
769
|
+
// Internals: placement and housekeeping
|
|
770
|
+
/** Insert after the last node of the same subnet, so a subnet's children stay together in the file. */
|
|
771
|
+
insertNode(node, parent) {
|
|
772
|
+
if (parent === null) {
|
|
773
|
+
this.document.nodes.push(node);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const family = new Set([parent, ...this.descendantsOf(parent).map((child) => child.id)]);
|
|
777
|
+
let at = -1;
|
|
778
|
+
this.document.nodes.forEach((candidate, index) => {
|
|
779
|
+
if (family.has(candidate.id))
|
|
780
|
+
at = index;
|
|
781
|
+
});
|
|
782
|
+
this.document.nodes.splice(at + 1, 0, node);
|
|
783
|
+
}
|
|
784
|
+
placeFor(node) {
|
|
785
|
+
const parent = parentOf(node);
|
|
786
|
+
const siblings = this.childrenOf(parent).filter((other) => other.id !== node.id);
|
|
787
|
+
const upstream = [];
|
|
788
|
+
const downstream = [];
|
|
789
|
+
for (const { wire } of this.wires()) {
|
|
790
|
+
if (!wire)
|
|
791
|
+
continue;
|
|
792
|
+
if (wire.to.node === node.id && wire.from.node !== node.id) {
|
|
793
|
+
const source = this.findNode(wire.from.node);
|
|
794
|
+
if (source && parentOf(source) === parent)
|
|
795
|
+
upstream.push(positionOf(source));
|
|
796
|
+
}
|
|
797
|
+
if (wire.from.node === node.id && wire.to.node !== node.id) {
|
|
798
|
+
const target = this.findNode(wire.to.node);
|
|
799
|
+
if (target && parentOf(target) === parent)
|
|
800
|
+
downstream.push(positionOf(target));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return placeNode({ siblings: siblings.map(positionOf), upstream, downstream, step: this.stepIn(parent, node.id) });
|
|
804
|
+
}
|
|
805
|
+
relayout(node) {
|
|
806
|
+
const before = positionOf(node);
|
|
807
|
+
const after = this.placeFor(node);
|
|
808
|
+
if (before.x === after.x && before.y === after.y)
|
|
809
|
+
return undefined;
|
|
810
|
+
setPosition(node, after);
|
|
811
|
+
return this.summary(node);
|
|
812
|
+
}
|
|
813
|
+
/** The flow of one canvas: the median offset along its wires. */
|
|
814
|
+
stepIn(parent, exclude) {
|
|
815
|
+
const wires = [];
|
|
816
|
+
for (const { wire } of this.wires()) {
|
|
817
|
+
// The node being placed does not get a say in the flow it is placed by.
|
|
818
|
+
if (!wire || wire.from.node === exclude || wire.to.node === exclude)
|
|
819
|
+
continue;
|
|
820
|
+
const source = this.findNode(wire.from.node);
|
|
821
|
+
const target = this.findNode(wire.to.node);
|
|
822
|
+
if (!source || !target || parentOf(source) !== parent || parentOf(target) !== parent)
|
|
823
|
+
continue;
|
|
824
|
+
wires.push({ from: positionOf(source), to: positionOf(target) });
|
|
825
|
+
}
|
|
826
|
+
return flowStep(wires);
|
|
827
|
+
}
|
|
828
|
+
forgetInAnnotations(removed) {
|
|
829
|
+
const annotations = this.document.annotations;
|
|
830
|
+
if (!Array.isArray(annotations))
|
|
831
|
+
return;
|
|
832
|
+
this.document.annotations = annotations.filter((annotation) => !removed.has(String(annotation.parent ?? "")));
|
|
833
|
+
for (const annotation of this.document.annotations) {
|
|
834
|
+
if (Array.isArray(annotation.containedElements)) {
|
|
835
|
+
annotation.containedElements = annotation.containedElements.filter((id) => !removed.has(String(id)));
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (!this.document.annotations.length && annotations.length)
|
|
839
|
+
delete this.document.annotations;
|
|
840
|
+
}
|
|
841
|
+
uniqueId(base) {
|
|
842
|
+
if (!this.findNode(base))
|
|
843
|
+
return base;
|
|
844
|
+
for (let n = 2;; n += 1)
|
|
845
|
+
if (!this.findNode(`${base}-${n}`))
|
|
846
|
+
return `${base}-${n}`;
|
|
847
|
+
}
|
|
848
|
+
summary(node) {
|
|
849
|
+
return { id: node.id, module: moduleOf(node), parent: parentOf(node), position: positionOf(node) };
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
/** `a.b.port` splits at the last dot: node `a.b`, port `port`. No dot: the node alone. */
|
|
854
|
+
export function splitEndpoint(text) {
|
|
855
|
+
const at = text.lastIndexOf(".");
|
|
856
|
+
return at === -1 ? { node: text, port: "" } : { node: text.slice(0, at), port: text.slice(at + 1) };
|
|
857
|
+
}
|
|
858
|
+
/** `project.InkVocabulary` becomes `ink-vocabulary`; `cascade.geo.CopyToPoints`, `copy-to-points`. */
|
|
859
|
+
export function defaultIdFor(module) {
|
|
860
|
+
return slug(module.slice(module.lastIndexOf(".") + 1)) || "node";
|
|
861
|
+
}
|
|
862
|
+
function slug(text) {
|
|
863
|
+
return text
|
|
864
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
865
|
+
.replace(/[^A-Za-z0-9]+/g, "-")
|
|
866
|
+
.replace(/^-+|-+$/g, "")
|
|
867
|
+
.toLowerCase();
|
|
868
|
+
}
|
|
869
|
+
function assertValidId(id) {
|
|
870
|
+
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(id)) {
|
|
871
|
+
throw new GraphEditError(`"${id}" is not a usable node id: letters, digits, - and _, starting with a letter`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
function sourceFor(module) {
|
|
875
|
+
if (module.startsWith("cascade."))
|
|
876
|
+
return "stdlib";
|
|
877
|
+
if (module.startsWith("project."))
|
|
878
|
+
return "project";
|
|
879
|
+
return undefined;
|
|
880
|
+
}
|
|
881
|
+
/** Port names that say nothing about what flows through them. */
|
|
882
|
+
const GENERIC_PORTS = new Set(["value", "output", "out", "input", "in", "result", "data"]);
|
|
883
|
+
function isBoundary(node) {
|
|
884
|
+
return moduleOf(node) === INPUT || moduleOf(node) === OUTPUT;
|
|
885
|
+
}
|
|
886
|
+
function portNumber(port) {
|
|
887
|
+
const match = /_(\d+)$/.exec(port);
|
|
888
|
+
return match ? Number(match[1]) : -1;
|
|
889
|
+
}
|
|
890
|
+
function along(point, step) {
|
|
891
|
+
return point.x * step.x + point.y * step.y;
|
|
892
|
+
}
|
|
893
|
+
//# sourceMappingURL=graphEditor.js.map
|