From b2350b7506edcf60cf378f815b720a551513f9b2 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 21 Jul 2026 23:38:12 +0300 Subject: [PATCH 1/9] [feat] flow v2 H1 - true per-object graph documents - flowGraphs store (keyed 'scene' | objectUuid) is the source of truth; the legacy flowNodes/flowEdges stores become the ACTIVE graph's editor view, kept in sync by a mirror in flowStore.js (leaf module - history.js imports flowRuntime, so the mirror cannot live behind a module that imports history) - flowGraphs.js: createObjectGraph/deleteObjectGraph (replicated graphcreate/ graphdelete + 'flowgraph' history kind - undo of delete restores the whole document and re-replicates it), requestDeleteObjectGraph confirmation flow, serializeGraphs with orphan pruning AT SERIALIZATION ONLY (undoing an object delete finds its flow intact) - nodesHandler: every applier takes the target graph (missing graphId = scene, legacy compat); full-state 'nodes' message carries a graphs map + legacy scene fields; graphHash/nodesync/sendNodes cover every graph - flowRuntime + physics collectParams iterate ALL graphs; an effect/physics/ sound/onclick node inside an object graph with no Object Selector wired implicitly targets the graph's OWNER object (explicit wiring still wins); C2 live angvel/motor re-apply now subscribes flowGraphs - editor: scope follows the viewport selection (scene when nothing selected); scope chip + delete-flow button; no-flow objects show an empty state with one-click Create flow (palette adds also auto-create); peer cursors filter to the graph being viewed; all editor broadcasts tag graphId - sessions/autosave persist the full graphs map (legacy nodes/edges kept for old builds; old snapshots load as the scene graph); stash clears all graphs - cross-graph readers fixed: pathCapture, sceneAssets, Explorer live-script, button module trigger lookup, Inspector physics rows - e2e: flow-object-graphs (17 checks incl. two-peer replication + undo restore, all pass); regression: button, flow-nodes-core, flow-physics-nodes all pass; path-node's patrol-drift failure reproduces on MAIN (pre-existing machine flake, not H1); build green; svelte-check 501/77 held Co-Authored-By: Claude Fable 5 --- src/App.svelte | 7 +- src/components/editors/Explorer.svelte | 13 +- src/components/editors/Nodes.svelte | 93 ++++++++-- src/components/editors/PeerCursors.svelte | 5 +- src/components/menu/Inspector.svelte | 6 +- src/lib/autosave.js | 27 ++- src/lib/customNodes.js | 30 +-- src/lib/flowGraphs.js | 137 ++++++++++++++ src/lib/flowRuntime.js | 61 ++++-- src/lib/nodesHandler.js | 214 +++++++++++++++------- src/lib/pathCapture.js | 16 +- src/lib/peerHandler.svelte.js | 19 +- src/lib/physics.js | 38 ++-- src/lib/sceneAssets.js | 6 +- src/lib/sessions.js | 40 ++-- src/modules/button/module.js | 9 +- src/stores/flowStore.js | 192 ++++++++++++++++++- tests/e2e/flow-object-graphs.test.cjs | 119 ++++++++++++ 18 files changed, 852 insertions(+), 180 deletions(-) create mode 100644 src/lib/flowGraphs.js create mode 100644 tests/e2e/flow-object-graphs.test.cjs diff --git a/src/App.svelte b/src/App.svelte index f8ea1a6d..5106ff22 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -137,9 +137,10 @@ import('./lib/ai/tools'), import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), - import('./lib/ai/meshJobs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib } + import('./lib/ai/meshJobs'), + import('./lib/flowGraphs') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib } }) } }) diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 11154c71..ccad9006 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -44,7 +44,7 @@ import { prefabs, loadPrefabs } from '$lib/prefabs'; import { sceneAssets } from '$lib/sceneAssets'; import { setNodeData } from '$lib/nodesHandler'; - import { flowNodes } from '../../stores/flowStore'; + import { findNodeAnyGraph } from '../../stores/flowStore'; import { bottomDockActive, visibleDockKey, setDockOccupant } from '$lib/bottomDock'; import { dragWindow } from '$lib/dragWindow'; import { focusStack } from '$lib/windowFocus'; @@ -784,14 +784,13 @@ // scripts edit the NODE code (replicated via setNodeData) if (item.kind === 'image' && item.dataUrl) openImagePreview({ title: item.name, url: item.dataUrl, onClose: () => gridEl?.focus() }); else if (item.kind === 'text' && item.nodeId) { - let nodes: any[] = []; - flowNodes.subscribe((v: any) => (nodes = v))(); - const node = nodes.find((n) => n.id === item.nodeId); - if (node) + // H1: the script node can live in any graph document + const found = findNodeAnyGraph((n: any) => n.id === item.nodeId); + if (found) openTextEditor({ title: item.name + ' (live script)', - code: node.data?.code ?? '', - onSave: (code: string) => setNodeData(item.nodeId, { code }), + code: found.node.data?.code ?? '', + onSave: (code: string) => setNodeData(item.nodeId, { code }, found.graphId), onClose: () => gridEl?.focus() }); } else if (item.kind === 'audio' && item.itemId) { diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index 569fdddb..cdb60cde 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -41,7 +41,9 @@ import EffectNode from './nodes/EffectNode.svelte'; import OnClickNode from './nodes/OnClickNode.svelte'; import CounterNode from './nodes/CounterNode.svelte'; - import { flowNodes as nodes, flowEdges as edges, customNodeDefs, nodeDesignerOpen } from '../../stores/flowStore'; + import { flowNodes as nodes, flowEdges as edges, customNodeDefs, nodeDesignerOpen, flowGraphs, activeGraphId, SCENE_GRAPH, setActiveGraph } from '../../stores/flowStore'; + import { createObjectGraph, requestDeleteObjectGraph } from '$lib/flowGraphs'; + import { objectsGroup, selectedObject } from '../../stores/sceneStore'; import { serializeNode, serializeEdge, deleteFlowNodes, deleteFlowEdges, setNodeData } from '$lib/nodesHandler'; import ThemedSelect from '../ui/ThemedSelect.svelte'; import { defDefaults } from '$lib/customNodes'; @@ -126,6 +128,22 @@ $: bgVariant = bgPattern === 'lines' ? BG_LINES : BG_DOTS; $: selectedNode = ($nodes as any[]).find((n) => n.selected) ?? null; + // H1 (flow v2): the editor scope follows the viewport selection — a selected + // object shows ITS graph (or the create-flow empty state), deselecting returns + // to the scene graph. setActiveGraph no-ops on repeats. + $: { + const uuid = ($selectedObject as any)?.uuid; + setActiveGraph(uuid ?? SCENE_GRAPH); + } + $: activeId = $activeGraphId; + $: hasActiveGraph = activeId === SCENE_GRAPH || !!$flowGraphs[activeId]; + $: activeOwnerName = + activeId === SCENE_GRAPH + ? 'Scene' + : ($objectsGroup as any)?.getObjectByProperty?.('uuid', activeId)?.name || + ($objectsGroup as any)?.getObjectByProperty?.('uuid', activeId)?.type || + activeId.slice(0, 8); + function setEdgeStyle(style: string) { edgeStyle = style; LS?.setItem('flowEdgeStyle', style); @@ -153,7 +171,8 @@ id: peer.peer.id, name: $username || peer.peer.id, x: position.x, - y: position.y + y: position.y, + graphId: activeId }); }; const onPointerLeaveCursor = () => { @@ -164,6 +183,10 @@ let menu: any = null; function addNode(type: string, label: string, position: { x: number; y: number }, extraDefaults: any = null) { + // H1: adding a node to a selected object that has no flow yet CREATES the + // flow implicitly (replicated + undoable) — the palette stays usable from + // the empty state. + if (activeId !== SCENE_GRAPH && !hasActiveGraph) createObjectGraph(activeId); const spec = findNodeSpec(type); const newNode = { id: crypto.randomUUID(), @@ -180,7 +203,7 @@ nodes.update((nodes) => [...nodes, newNode]); // Replicate the new node to all peers - peer?.send({ type: 'nodecreate', node: serializeNode(newNode) }); + peer?.send({ type: 'nodecreate', node: serializeNode(newNode), graphId: activeId }); } // Touch has no HTML5 drag-and-drop, so a palette TAP adds the node at the flow @@ -238,7 +261,7 @@ // Replicate node positions when a drag ends const onNodeDragStop = (event: CustomEvent<{ nodes: Node[] }>) => { event.detail.nodes.forEach((node) => { - peer?.send({ type: 'nodemove', id: node.id, position: { x: node.position.x, y: node.position.y } }); + peer?.send({ type: 'nodemove', id: node.id, position: { x: node.position.x, y: node.position.y }, graphId: activeId }); }); }; @@ -262,36 +285,36 @@ type: edgeStyle, markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 } } satisfies Edge; - peer?.send({ type: 'edgecreate', edge: serializeEdge(edge) }); + peer?.send({ type: 'edgecreate', edge: serializeEdge(edge), graphId: activeId }); return edge; }; // Replicate deletions (Backspace / Delete key) const ondelete = ({ nodes: deletedNodes, edges: deletedEdges }: { nodes: Node[]; edges: Edge[] }) => { if (deletedNodes.length) - peer?.send({ type: 'nodedelete', ids: deletedNodes.map((n) => n.id) }); + peer?.send({ type: 'nodedelete', ids: deletedNodes.map((n) => n.id), graphId: activeId }); if (deletedEdges.length) - peer?.send({ type: 'edgedelete', ids: deletedEdges.map((e) => e.id) }); + peer?.send({ type: 'edgedelete', ids: deletedEdges.map((e) => e.id), graphId: activeId }); }; // --- context menus --- function deleteNode(id: string) { - deleteFlowNodes([id]); - peer?.send({ type: 'nodedelete', ids: [id] }); + deleteFlowNodes([id], activeId); + peer?.send({ type: 'nodedelete', ids: [id], graphId: activeId }); } function disconnectNode(id: string) { const ids = $edges.filter((e) => e.source === id || e.target === id).map((e) => e.id); if (ids.length) { - deleteFlowEdges(ids); - peer?.send({ type: 'edgedelete', ids: ids }); + deleteFlowEdges(ids, activeId); + peer?.send({ type: 'edgedelete', ids: ids, graphId: activeId }); } } function deleteEdge(id: string) { - deleteFlowEdges([id]); - peer?.send({ type: 'edgedelete', ids: [id] }); + deleteFlowEdges([id], activeId); + peer?.send({ type: 'edgedelete', ids: [id], graphId: activeId }); } const onPaneContextMenu = (event: CustomEvent<{ event: MouseEvent }>) => { @@ -337,7 +360,7 @@ ...(src.class ? { class: src.class } : {}) } as any; nodes.update((ns: any[]) => [...ns, copy]); - peer?.send({ type: 'nodecreate', node: serializeNode(copy) }); + peer?.send({ type: 'nodecreate', node: serializeNode(copy), graphId: activeId }); } const onNodeContextMenu = (event: CustomEvent<{ event: MouseEvent; node: Node }>) => { @@ -514,6 +537,48 @@ on:pointermove={onPointerMoveCursor} on:pointerleave={onPointerLeaveCursor} > + +
+ + {activeId === SCENE_GRAPH ? 'Scene flow' : activeOwnerName + ' — object flow'} + + {#if activeId !== SCENE_GRAPH && hasActiveGraph} + + {/if} +
+ + + {#if activeId !== SCENE_GRAPH && !hasActiveGraph} +
+

+ {activeOwnerName} has no flow yet +

+ +

Nodes here will drive this object (no Object Selector needed)

+
+ {/if} import { onMount, onDestroy } from 'svelte'; - import { flowCursors } from '../../stores/flowStore'; + import { flowCursors, activeGraphId, SCENE_GRAPH } from '../../stores/flowStore'; // Renders connected peers' cursors inside the flow editor. Coordinates arrive // in flow space; the current viewport (pan/zoom) converts them to screen space. @@ -31,7 +31,8 @@
- {#each Object.entries($flowCursors) as [id, cursor] (id)} + + {#each Object.entries($flowCursors).filter(([, c]) => ((c as any).graphId ?? SCENE_GRAPH) === $activeGraphId) as [id, cursor] (id)}
{ $objectsGroup; - $flowNodes; - $flowEdges; + $flowGraphs; $selectedObject; return listPhysicsObjects(); }); diff --git a/src/lib/autosave.js b/src/lib/autosave.js index ac7c901e..12ac303c 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -2,7 +2,8 @@ import { get, writable } from 'svelte/store'; import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { objectsGroup, globalCamera, orbitControls } from '../stores/sceneStore'; -import { flowNodes, flowEdges } from '../stores/flowStore'; +import { flowGraphs, restoreGraphs, SCENE_GRAPH } from '../stores/flowStore'; +import { serializeGraphs } from './flowGraphs'; import { serializeNode, serializeEdge } from './nodesHandler'; import { parkAnimatedAtBase } from './flowRuntime'; import { peers, showToast } from '../stores/appStore'; @@ -50,11 +51,18 @@ function exportScene() { async function saveSnapshot() { if (!get(autosaveEnabled)) return; - const nodes = get(flowNodes).map(serializeNode); - const edges = get(flowEdges).map(serializeEdge); + // H1: persist EVERY graph document; orphan object graphs (owner object gone) + // are pruned from the OUTPUT only. Legacy nodes/edges fields keep carrying the + // scene graph so an old build can still restore this snapshot. + const group = get(objectsGroup); + const graphs = serializeGraphs(serializeNode, serializeEdge, { + pruneMissing: (uuid) => !group?.getObjectByProperty?.('uuid', uuid) + }); + const nodes = graphs[SCENE_GRAPH]?.nodes ?? []; + const edges = graphs[SCENE_GRAPH]?.edges ?? []; const scene = await exportScene(); // never overwrite a good snapshot with emptiness - if (!scene && nodes.length === 0) return; + if (!scene && nodes.length === 0 && Object.keys(graphs).length <= 1) return; /** @type {any} */ const camera = get(globalCamera); /** @type {any} */ @@ -65,6 +73,7 @@ async function saveSnapshot() { scene, nodes, edges, + graphs, annotations: annotationsProvider ? annotationsProvider() : [], camera: camera ? { position: camera.position.toArray(), target: controls?.target?.toArray() ?? [0, 0, 0] } @@ -149,9 +158,10 @@ export async function restoreSnapshot() { }); objectsGroup.update((value) => value); } - if (snapshot.nodes?.length || snapshot.edges?.length) { - flowNodes.set(snapshot.nodes ?? []); - flowEdges.set(snapshot.edges ?? []); + if (snapshot.graphs && typeof snapshot.graphs === 'object') { + restoreGraphs(snapshot.graphs); // H1 format: every graph document + } else if (snapshot.nodes?.length || snapshot.edges?.length) { + restoreGraphs({ [SCENE_GRAPH]: { nodes: snapshot.nodes ?? [], edges: snapshot.edges ?? [] } }); } if (snapshot.annotations?.length && annotationsRestorer) annotationsRestorer(snapshot.annotations); /** @type {any} */ @@ -191,8 +201,7 @@ export function startAutosave() { if (started || typeof window === 'undefined') return; started = true; objectsGroup.subscribe(() => markDirty()); - flowNodes.subscribe(() => markDirty()); - flowEdges.subscribe(() => markDirty()); + flowGraphs.subscribe(() => markDirty()); // H1: any graph document change setInterval(() => { if (dirty) saveSnapshot(); }, INTERVAL_MS); diff --git a/src/lib/customNodes.js b/src/lib/customNodes.js index c765879c..ae2ddabc 100644 --- a/src/lib/customNodes.js +++ b/src/lib/customNodes.js @@ -1,5 +1,5 @@ import { get } from 'svelte/store'; -import { customNodeDefs, flowNodes, flowEdges } from '../stores/flowStore'; +import { customNodeDefs, flowGraphs, updateGraph } from '../stores/flowStore'; import { peers } from '../stores/appStore'; // User-designed node definitions ({id, name, params[], code}) — replicated @@ -36,19 +36,27 @@ export function applyNodeDef(def) { } /** B4.5: drop edges targeting a custom-node input whose def param no longer - * exists (deleted-handle edges would dangle + diverge). @param {string} defId */ + * exists (deleted-handle edges would dangle + diverge). H1: instances can live + * in ANY graph document — prune each graph. @param {string} defId */ export function pruneCustomNodeEdges(defId) { const def = get(customNodeDefs).find((d) => d.id === defId); const valid = new Set((def?.params ?? []).filter((/** @type {any} */ p) => p.kind === 'range').map((/** @type {any} */ p) => p.key)); - const instances = new Set( - get(flowNodes) - .filter((n) => n.data?.defId === defId) - .map((n) => n.id) - ); - if (!instances.size) return; - flowEdges.update((edges) => - edges.filter((e) => !(instances.has(e.target) && e.targetHandle && !valid.has(e.targetHandle))) - ); + for (const [graphId, graph] of Object.entries(get(flowGraphs))) { + const instances = new Set( + graph.nodes.filter((/** @type {any} */ n) => n.data?.defId === defId).map((/** @type {any} */ n) => n.id) + ); + if (!instances.size) continue; + const dangling = graph.edges.some( + (/** @type {any} */ e) => instances.has(e.target) && e.targetHandle && !valid.has(e.targetHandle) + ); + if (dangling) + updateGraph(graphId, (g) => ({ + nodes: g.nodes, + edges: g.edges.filter( + (/** @type {any} */ e) => !(instances.has(e.target) && e.targetHandle && !valid.has(e.targetHandle)) + ) + })); + } } /** B4.5: snapshot post-pass — a stale peer's snapshot must not resurrect diff --git a/src/lib/flowGraphs.js b/src/lib/flowGraphs.js new file mode 100644 index 00000000..3f3eec13 --- /dev/null +++ b/src/lib/flowGraphs.js @@ -0,0 +1,137 @@ +import { get } from 'svelte/store'; +import { + flowGraphs, + SCENE_GRAPH, + graphExists, + graphOf, + updateGraph, + removeGraphDocument +} from '../stores/flowStore'; +import { peers, showToast } from '../stores/appStore'; +import { registerHistoryKind, recordEntry } from './history'; + +// H1 (flow v2): object-flow lifecycle -- create/delete graph documents, +// replicated (graphcreate/graphdelete) and undoable (the 'flowgraph' history +// kind). The view<->store mirror and the whole-world read helpers live in +// flowStore.js (leaf module) so the runtime can use them without closing an +// import cycle through history.js. + +// --- lifecycle --------------------------------------------------------------- + +/** @param {string} uuid @param {{replicate?: boolean, record?: boolean}} [opts] */ +export function createObjectGraph(uuid, opts = {}) { + const { replicate = true, record = true } = opts; + if (!uuid || uuid === SCENE_GRAPH || graphExists(uuid)) return; + updateGraph(uuid, () => ({ nodes: [], edges: [] })); + if (record) + recordEntry({ + kind: 'flowgraph', + op: 'create', + uuid, + graph: { nodes: [], edges: [] }, + before: 'before', + after: 'after' + }); + /** @type {any} */ + const peer = get(peers); + if (replicate && peer) peer.send({ type: 'graphcreate', uuid }); +} + +/** @param {string} uuid @param {{replicate?: boolean, record?: boolean}} [opts] */ +export function deleteObjectGraph(uuid, opts = {}) { + const { replicate = true, record = true } = opts; + const graph = graphOf(uuid); + if (!graph || uuid === SCENE_GRAPH) return; + if (record) + recordEntry({ + kind: 'flowgraph', + op: 'delete', + uuid, + graph: { + nodes: graph.nodes.map((/** @type {any} */ n) => ({ ...n })), + edges: graph.edges.map((/** @type {any} */ e) => ({ ...e })) + }, + before: 'before', + after: 'after' + }); + removeGraphDocument(uuid); + /** @type {any} */ + const peer = get(peers); + if (replicate && peer) peer.send({ type: 'graphdelete', uuid }); +} + +// remote appliers (no re-broadcast) -- golden rule 1 +/** @param {string} uuid */ +export function applyGraphCreate(uuid) { + if (!uuid || uuid === SCENE_GRAPH || graphExists(uuid)) return; + updateGraph(uuid, (g) => g); +} +/** @param {string} uuid */ +export function applyGraphDelete(uuid) { + removeGraphDocument(uuid); +} + +// 'flowgraph' history kind: undo of create removes the graph; undo of delete +// restores the captured document (and replicates the restoration). +registerHistoryKind('flowgraph', (entry, state) => { + const undoing = state === entry.before; + const shouldExist = entry.op === 'create' ? !undoing : undoing; + if (shouldExist) { + updateGraph(entry.uuid, () => ({ + nodes: entry.graph.nodes.map((/** @type {any} */ n) => ({ ...n })), + edges: entry.graph.edges.map((/** @type {any} */ e) => ({ ...e })) + })); + /** @type {any} */ + const peer = get(peers); + if (peer) { + peer.send({ type: 'graphcreate', uuid: entry.uuid }); + if (entry.graph.nodes.length || entry.graph.edges.length) + peer.send({ type: 'nodes', graphs: { [entry.uuid]: entry.graph } }); + } + } else { + deleteObjectGraph(entry.uuid, { record: false }); + } + return true; +}); + +// --- serialization helpers ----------------------------------------------------- + +/** + * Serialize every graph via the caller-supplied node/edge serializers + * (nodesHandler owns those; passed in to keep this module import-light). + * @param {(n: any) => any} serializeNode @param {(e: any) => any} serializeEdge + * @param {{pruneMissing?: (uuid: string) => boolean}} [opts] pruneMissing returns + * true when the OWNER OBJECT no longer exists -- orphan graphs are dropped + * from the OUTPUT only (kept live in the store so undoing an object delete + * finds its flow intact). + */ +export function serializeGraphs(serializeNode, serializeEdge, opts = {}) { + const { pruneMissing } = opts; + /** @type {Record} */ + const out = {}; + for (const [graphId, graph] of Object.entries(get(flowGraphs))) { + if (graphId !== SCENE_GRAPH && pruneMissing && pruneMissing(graphId)) continue; + out[graphId] = { + nodes: graph.nodes.map(serializeNode), + edges: graph.edges.map(serializeEdge) + }; + } + return out; +} + +/** Guarded delete for the editor UI: confirmation toast, then replicated delete. + * @param {string} uuid @param {string} label object name for the message */ +export function requestDeleteObjectGraph(uuid, label) { + const graph = graphOf(uuid); + if (!graph) return; + const count = graph.nodes.length; + showToast( + 'Delete the flow of "' + (label || 'object') + '"? ' + + count + ' node' + (count === 1 ? '' : 's') + ' will be removed for everyone.', + [ + { label: 'Delete flow', action: () => deleteObjectGraph(uuid) }, + { label: 'Cancel', action: () => {} } + ] + ); +} + diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 4ea86e03..f356eb09 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { get } from 'svelte/store'; -import { flowNodes, flowEdges, mutedFlowObjects, syncedAnimations, flowValues, flowTriggers } from '../stores/flowStore'; +import { flowGraphs, mutedFlowObjects, syncedAnimations, flowValues, flowTriggers, SCENE_GRAPH, startGraphMirror, allNodes, allEdges } from '../stores/flowStore'; import { objectsGroup } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; import { animationTypes } from './nodeCatalog'; @@ -61,6 +61,20 @@ function targetUuidOf(edge) { return selected; } +// H1: inside an OBJECT graph, an effect/source node that is NOT wired into any +// objectselector implicitly targets the graph's owner object. Explicit selector +// wiring always wins (lets an object graph drive other objects too). +/** @param {any} node @returns {string | null} the owner uuid or null */ +function implicitOwnerOf(node) { + const graph = node.__graph; + if (!graph || graph === SCENE_GRAPH) return null; + if (muted.includes(graph)) return null; + const wired = edges.some( + (e) => e.source === node.id && nodes.find((n) => n.id === e.target)?.type === 'objectselector' + ); + return wired ? null : graph; +} + function applyColors() { if (!sceneObjects) return; edges.forEach((edge) => { @@ -450,7 +464,8 @@ export function fireObjectClick(uuid) { const target = nodes.find((n) => n.id === edge.target); return target?.type === 'objectselector' && target.data?.selected === uuid; }); - if (hit) applyNodeTrigger(node.id, syncedNow(), true); + // H1: an unwired OnClick inside the clicked object's own graph also fires + if (hit || implicitOwnerOf(node) === uuid) applyNodeTrigger(node.id, syncedNow(), true); }); } @@ -582,22 +597,29 @@ function tick(now) { // collect active animations per scene object const active = new Map(); // uuid -> anim nodes + /** @param {any} node */ + const isEffectNode = (node) => + animationTypes.includes(node.type) || + !!moduleEffects[node.type] || + node.type === 'script' || + node.type === 'customnode'; if (sceneObjects) { edges.forEach((edge) => { const source = nodes.find((n) => n.id === edge.source); - if ( - !source || - (!animationTypes.includes(source.type) && - !moduleEffects[source.type] && - source.type !== 'script' && - source.type !== 'customnode') - ) - return; + if (!source || !isEffectNode(source)) return; const uuid = targetUuidOf(edge); if (!uuid) return; if (!active.has(uuid)) active.set(uuid, []); active.get(uuid).push(source); }); + // H1: object-graph effects with no explicit selector target their owner + nodes.forEach((node) => { + if (!isEffectNode(node)) return; + const uuid = implicitOwnerOf(node); + if (!uuid) return; + if (!active.has(uuid)) active.set(uuid, []); + if (!active.get(uuid).includes(node)) active.get(uuid).push(node); + }); } // restore objects whose animations were disconnected/deleted @@ -633,6 +655,12 @@ function tick(now) { // resolve input-driven volume/radius (133) without touching soundRuntime if (uuid) soundPairs.push({ node: { ...source, data: resolveInputs(source, nodes, edges, time, ctx) }, uuid }); }); + // H1: sound nodes in object graphs attach to their owner when unwired + nodes.forEach((node) => { + if (node.type !== 'sound') return; + const uuid = implicitOwnerOf(node); + if (uuid) soundPairs.push({ node: { ...node, data: resolveInputs(node, nodes, edges, time, ctx) }, uuid }); + }); updateSounds(soundPairs, sceneObjects, time); // live value/logic readouts (133): recompute ~6/s and publish for the cards @@ -682,12 +710,13 @@ export function startFlowRuntime() { if (started || typeof window === 'undefined') return; started = true; - flowNodes.subscribe((value) => { - nodes = value; - applyColors(); - }); - flowEdges.subscribe((value) => { - edges = value; + // H1: the runtime sees EVERY graph (scene + per-object documents) as one + // combined node/edge set; nodes carry a runtime-only __graph tag used for + // implicit-owner targeting. The mirror keeps the editor view in sync. + startGraphMirror(); + flowGraphs.subscribe(() => { + nodes = allNodes(); + edges = allEdges(); applyColors(); }); objectsGroup.subscribe((value) => { diff --git a/src/lib/nodesHandler.js b/src/lib/nodesHandler.js index cfd4031f..d5f840b5 100644 --- a/src/lib/nodesHandler.js +++ b/src/lib/nodesHandler.js @@ -1,8 +1,18 @@ import { get } from 'svelte/store'; -import { flowNodes, flowEdges, flowCursors } from '../stores/flowStore'; +import { + flowNodes, + flowEdges, + flowGraphs, + flowCursors, + SCENE_GRAPH, + updateGraph, + graphTotals, + activeGraphId +} from '../stores/flowStore'; import { peers } from '../stores/appStore'; -// Strip runtime-only fields (computed, selected, dragging) so the node is serializable for peerjs +// Strip runtime-only fields (computed, selected, dragging, __graph) so the node +// is serializable for peerjs /** @param {any} node */ export function serializeNode(node) { return { @@ -28,41 +38,56 @@ export function serializeEdge(edge) { }; } -// --- Remote appliers (no re-broadcast, plain store updates) --- +// --- Remote appliers (no re-broadcast). H1: every applier takes the graph the +// message targets (absent on old-format messages = the scene graph) and routes +// through updateGraph, which mirrors into the editor view when active. --- -/** @param {any} node */ -export function createFlowNode(node) { - flowNodes.update((nodes) => (nodes.some((n) => n.id === node.id) ? nodes : [...nodes, node])); +/** @param {any} node @param {string} [graphId] */ +export function createFlowNode(node, graphId = SCENE_GRAPH) { + updateGraph(graphId, (g) => ({ + nodes: g.nodes.some((n) => n.id === node.id) ? g.nodes : [...g.nodes, node], + edges: g.edges + })); } -/** @param {string} id @param {{x: number, y: number}} position */ -export function moveFlowNode(id, position) { - flowNodes.update((nodes) => nodes.map((n) => (n.id === id ? { ...n, position } : n))); +/** @param {string} id @param {{x: number, y: number}} position @param {string} [graphId] */ +export function moveFlowNode(id, position, graphId = SCENE_GRAPH) { + updateGraph(graphId, (g) => ({ + nodes: g.nodes.map((n) => (n.id === id ? { ...n, position } : n)), + edges: g.edges + })); } -/** @param {string} id @param {any} data */ -export function updateFlowNodeData(id, data) { - flowNodes.update((nodes) => - nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, ...data } } : n)) - ); +/** @param {string} id @param {any} data @param {string} [graphId] */ +export function updateFlowNodeData(id, data, graphId = SCENE_GRAPH) { + updateGraph(graphId, (g) => ({ + nodes: g.nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, ...data } } : n)), + edges: g.edges + })); } -/** @param {string[]} ids */ -export function deleteFlowNodes(ids) { - flowNodes.update((nodes) => nodes.filter((n) => !ids.includes(n.id))); - flowEdges.update((edges) => - edges.filter((e) => !ids.includes(e.source) && !ids.includes(e.target)) - ); +/** @param {string[]} ids @param {string} [graphId] */ +export function deleteFlowNodes(ids, graphId = SCENE_GRAPH) { + updateGraph(graphId, (g) => ({ + nodes: g.nodes.filter((n) => !ids.includes(n.id)), + edges: g.edges.filter((e) => !ids.includes(e.source) && !ids.includes(e.target)) + })); } -/** @param {any} edge */ -export function createFlowEdge(edge) { - flowEdges.update((edges) => (edges.some((e) => e.id === edge.id) ? edges : [...edges, edge])); +/** @param {any} edge @param {string} [graphId] */ +export function createFlowEdge(edge, graphId = SCENE_GRAPH) { + updateGraph(graphId, (g) => ({ + nodes: g.nodes, + edges: g.edges.some((e) => e.id === edge.id) ? g.edges : [...g.edges, edge] + })); } -/** @param {string[]} ids */ -export function deleteFlowEdges(ids) { - flowEdges.update((edges) => edges.filter((e) => !ids.includes(e.id))); +/** @param {string[]} ids @param {string} [graphId] */ +export function deleteFlowEdges(ids, graphId = SCENE_GRAPH) { + updateGraph(graphId, (g) => ({ + nodes: g.nodes, + edges: g.edges.filter((e) => !ids.includes(e.id)) + })); } /** Apply a peer's flow-editor cursor position (or remove it on leave) @param {any} data */ @@ -70,7 +95,14 @@ export function applyFlowCursor(data) { flowCursors.update((map) => { const next = { ...map }; if (data.leave) delete next[data.id]; - else next[data.id] = { x: data.x, y: data.y, name: data.name, ts: Date.now() }; + else + next[data.id] = { + x: data.x, + y: data.y, + name: data.name, + ts: Date.now(), + graphId: data.graphId ?? SCENE_GRAPH + }; return next; }); } @@ -88,46 +120,74 @@ export function dropPeerCursor(peerId) { }); } -// Merge a full snapshot received from a peer. Nodes we already have are -// updated in place (position + data) so drift heals when a resync arrives. -/** @param {any[]} nodes @param {any[]} edges */ -export function applyNodesSnapshot(nodes, edges) { - if (Array.isArray(nodes)) { - flowNodes.update((current) => { +/** Merge one graph's snapshot: known nodes update in place, unknown append. */ +/** @param {string} graphId @param {any[]} nodes @param {any[]} edges */ +function mergeGraphSnapshot(graphId, nodes, edges) { + updateGraph(graphId, (g) => { + let nextNodes = g.nodes; + if (Array.isArray(nodes)) { const incoming = new Map(nodes.map((n) => [n.id, n])); - const merged = current.map((n) => { + nextNodes = g.nodes.map((n) => { const update = incoming.get(n.id); if (!update) return n; incoming.delete(n.id); return { ...n, position: update.position, data: { ...n.data, ...update.data } }; }); - return [...merged, ...incoming.values()]; - }); + nextNodes = [...nextNodes, ...incoming.values()]; + } + let nextEdges = g.edges; + if (Array.isArray(edges)) { + const have = new Set(g.edges.map((e) => e.id)); + nextEdges = [...g.edges, ...edges.filter((e) => !have.has(e.id))]; + } + return { nodes: nextNodes, edges: nextEdges }; + }); +} + +// Merge a full snapshot received from a peer. Old format = {nodes, edges} for +// the scene graph; H1 format adds {graphs: {graphId: {nodes, edges}}}. Nodes we +// already have are updated in place (position + data) so drift heals when a +// resync arrives. +/** @param {any[]} nodes @param {any[]} edges @param {Record} [graphs] */ +export function applyNodesSnapshot(nodes, edges, graphs) { + if (graphs && typeof graphs === 'object') { + for (const [graphId, graph] of Object.entries(graphs)) { + mergeGraphSnapshot(graphId, graph?.nodes ?? [], graph?.edges ?? []); + } + } else if (Array.isArray(nodes) || Array.isArray(edges)) { + // legacy format (pre-H1 peer / old session): scene graph only + mergeGraphSnapshot(SCENE_GRAPH, nodes, edges); } - if (Array.isArray(edges)) edges.forEach(createFlowEdge); // B4.5: a stale snapshot must not resurrect edges into removed custom-node - // params — prune deterministically after every snapshot apply + // params -- prune deterministically after every snapshot apply import('./customNodes').then((m) => m.pruneAllCustomNodeEdges()); } // --- Drift detection: peers periodically exchange a graph hash and pull a // fresh snapshot when theirs differs (heals missed nodedata/move messages) --- -/** djb2 over the serialized graph, order-independent via sort */ +/** djb2 over ALL serialized graphs, order-independent via sort */ export function graphHash() { - const nodes = get(flowNodes).map(serializeNode).sort((a, b) => a.id.localeCompare(b.id)); - // hash edges by STRUCTURE only — cosmetic type/marker are LOCAL editor prefs - // (166), so different per-peer edge styles must not trigger a resync - const edges = get(flowEdges) - .map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - sourceHandle: e.sourceHandle ?? null, - targetHandle: e.targetHandle ?? null - })) - .sort((a, b) => a.id.localeCompare(b.id)); - const text = JSON.stringify([nodes, edges]); + const all = get(flowGraphs); + /** @type {any[]} */ + const parts = []; + for (const graphId of Object.keys(all).sort()) { + const graph = all[graphId]; + const nodes = graph.nodes.map(serializeNode).sort((a, b) => a.id.localeCompare(b.id)); + // hash edges by STRUCTURE only -- cosmetic type/marker are LOCAL editor + // prefs (166), so different per-peer edge styles must not trigger a resync + const edges = graph.edges + .map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + sourceHandle: e.sourceHandle ?? null, + targetHandle: e.targetHandle ?? null + })) + .sort((a, b) => a.id.localeCompare(b.id)); + if (nodes.length || edges.length) parts.push([graphId, nodes, edges]); + } + const text = JSON.stringify(parts); let hash = 5381; for (let i = 0; i < text.length; i++) hash = ((hash * 33) ^ text.charCodeAt(i)) >>> 0; return hash; @@ -146,7 +206,7 @@ export function applyNodeSync(data) { /** @type {any} */ const peer = get(peers); if (!peer) return; - const myCount = get(flowNodes).length + get(flowEdges).length; + const myCount = graphTotals(); if (data.count < myCount) return; // they pull from us instead if (data.count === myCount && data.peerId <= peer.peer.id) return; const now = Date.now(); @@ -154,7 +214,7 @@ export function applyNodeSync(data) { const conn = peer.connections[data.peerId]; if (!conn?.open) return; lastResyncRequest = now; - console.log('Node graph differs from ' + data.peerId + ' — requesting a snapshot'); + console.log('Node graph differs from ' + data.peerId + ' -- requesting a snapshot'); conn.send({ type: 'getnodes', sender: peer.peer.id }); } @@ -167,7 +227,7 @@ export function startNodeSync() { /** @type {any} */ const peer = get(peers); if (!peer) return; - const count = get(flowNodes).length + get(flowEdges).length; + const count = graphTotals(); if (count === 0) return; peer.send({ type: 'nodesync', peerId: peer.peer.id, hash: graphHash(), count: count }); }, 10000); @@ -175,34 +235,52 @@ export function startNodeSync() { // --- Broadcast helpers --- -// Update local node data and replicate it to all peers -/** @param {string} id @param {any} data */ -export function setNodeData(id, data) { - updateFlowNodeData(id, data); +// Update local node data and replicate it to all peers. `graphId` defaults to +// the ACTIVE graph -- node components call this from the editor. +/** @param {string} id @param {any} data @param {string} [graphId] */ +export function setNodeData(id, data, graphId) { + const target = graphId ?? get(activeGraphId); + updateFlowNodeData(id, data, target); /** @type {any} */ const peer = get(peers); - if (peer) peer.send({ type: 'nodedata', id: id, data: data }); + if (peer) peer.send({ type: 'nodedata', id: id, data: data, graphId: target }); } /** - * Sends the whole node graph to the given peer. - * Waits for our outgoing connection to exist and open — messages sent earlier are dropped by peerjs. + * Sends ALL node graphs to the given peer (late-joiner full state / drift heal). + * Keeps the legacy {nodes, edges} fields carrying the scene graph alongside the + * H1 {graphs} map. Waits for our outgoing connection to exist and open -- + * messages sent earlier are dropped by peerjs. * @param {string} peerId - The ID of the peer to send the nodes to. */ export function sendNodes(peerId, attempt = 0) { /** @type {any} */ const peer = get(peers); if (!peer) return; - const nodes = get(flowNodes).map(serializeNode); - const edges = get(flowEdges).map(serializeEdge); - if (nodes.length === 0 && edges.length === 0) return; + const all = get(flowGraphs); + /** @type {Record} */ + const graphs = {}; + let total = 0; + for (const [graphId, graph] of Object.entries(all)) { + const nodes = graph.nodes.map(serializeNode); + const edges = graph.edges.map(serializeEdge); + if (nodes.length === 0 && edges.length === 0) continue; + graphs[graphId] = { nodes, edges }; + total += nodes.length + edges.length; + } + if (total === 0) return; - // our connection back to this peer may still be getting (re)established — retry until it is open + // our connection back to this peer may still be getting (re)established -- retry until it is open const conn = peer.connections[peerId]; if (!conn || !conn.open) { if (attempt < 20) setTimeout(() => sendNodes(peerId, attempt + 1), 500); return; } - console.log('Sending ' + nodes.length + ' nodes and ' + edges.length + ' edges to ' + peerId); - conn.send({ type: 'nodes', nodes: nodes, edges: edges }); + console.log('Sending ' + total + ' nodes+edges across ' + Object.keys(graphs).length + ' graphs to ' + peerId); + conn.send({ + type: 'nodes', + graphs, + nodes: graphs[SCENE_GRAPH]?.nodes ?? [], + edges: graphs[SCENE_GRAPH]?.edges ?? [] + }); } diff --git a/src/lib/pathCapture.js b/src/lib/pathCapture.js index 73021952..aab94fd9 100644 --- a/src/lib/pathCapture.js +++ b/src/lib/pathCapture.js @@ -1,7 +1,7 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; import { objectsGroup } from '../stores/sceneStore'; -import { flowNodes } from '../stores/flowStore'; +import { findNodeAnyGraph } from '../stores/flowStore'; import { setNodeData } from './nodesHandler'; import { showToast } from '../stores/appStore'; @@ -31,11 +31,13 @@ export function togglePathCapture(nodeId) { export function capturePathClick(raycaster) { const nodeId = get(pathCaptureNode); if (!nodeId) return false; - const node = get(flowNodes).find((n) => n.id === nodeId); - if (!node) { + // H1: the capturing path node can live in any graph document + const found = findNodeAnyGraph((n) => n.id === nodeId); + if (!found) { pathCaptureNode.set(null); return false; } + const { node, graphId } = found; const group = get(objectsGroup); // clicks on existing waypoint markers are drags/removals, not new points const markers = group?.parent?.getObjectByName('path-waypoints'); @@ -49,8 +51,10 @@ export function capturePathClick(raycaster) { hits[0]?.point ?? (raycaster.ray.intersectPlane(groundPlane, planeHit) ? planeHit : null); if (point) - setNodeData(nodeId, { - points: [...(node.data.points ?? []), [point.x, point.y, point.z]] - }); + setNodeData( + nodeId, + { points: [...(node.data.points ?? []), [point.x, point.y, point.z]] }, + graphId + ); return true; } diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 2671983f..7a715e1b 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -3,6 +3,7 @@ import { backoffDelay } from '$lib/netBackoff'; import { sceneCommand, lockRestore, checkLocks, createObject, sendObjects, deleteObject, colorObject, createLoader, userData, handleDisconnected, specator, cameraSettings, objectParameters, applyClearScene } from './commandsHandler.svelte'; import { createGeometry, createLight, createGroup, changeName, moveGeometry, lockGeometry, moveCamera } from '$lib/geometries.svelte'; import { sendNodes, applyNodesSnapshot, applyNodeSync, createFlowNode, moveFlowNode, updateFlowNodeData, deleteFlowNodes, createFlowEdge, deleteFlowEdges, applyFlowCursor } from '$lib/nodesHandler'; +import { applyGraphCreate, applyGraphDelete } from '$lib/flowGraphs'; import { applyNodeTrigger } from '$lib/flowRuntime'; import { applyNodeDef, applyNodeDefDelete, applyNodeDefsSnapshot, sendNodeDefs } from '$lib/customNodes'; import { applyRemoteDuplicate } from '$lib/objectActions'; @@ -317,9 +318,13 @@ export class PeerConnection { } else if(data.type == 'getnodes') { deferUntilShareChoice('nodes', data.sender); } else if(data.type == 'nodes') { - applyNodesSnapshot(data.nodes, data.edges); + applyNodesSnapshot(data.nodes, data.edges, data.graphs); } else if(data.type == 'nodesync') { applyNodeSync(data); + } else if(data.type == 'graphcreate') { + applyGraphCreate(data.uuid); + } else if(data.type == 'graphdelete') { + applyGraphDelete(data.uuid); } else if(data.type == 'nodedef') { applyNodeDef(data.def); } else if(data.type == 'nodedefdelete') { @@ -329,17 +334,17 @@ export class PeerConnection { } else if(data.type == 'getnodedefs') { sendNodeDefs(data.sender); } else if(data.type == 'nodecreate') { - createFlowNode(data.node); + createFlowNode(data.node, data.graphId); } else if(data.type == 'nodemove') { - moveFlowNode(data.id, data.position); + moveFlowNode(data.id, data.position, data.graphId); } else if(data.type == 'nodedata') { - updateFlowNodeData(data.id, data.data); + updateFlowNodeData(data.id, data.data, data.graphId); } else if(data.type == 'nodedelete') { - deleteFlowNodes(data.ids); + deleteFlowNodes(data.ids, data.graphId); } else if(data.type == 'edgecreate') { - createFlowEdge(data.edge); + createFlowEdge(data.edge, data.graphId); } else if(data.type == 'edgedelete') { - deleteFlowEdges(data.ids); + deleteFlowEdges(data.ids, data.graphId); } else if(data.type == 'nodetrigger') { applyNodeTrigger(data.id, data.t, false); // 134: shared-timestamp pulse } else if(data.type == 'flowcursor') { diff --git a/src/lib/physics.js b/src/lib/physics.js index daaf8bac..e92ff1e3 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; -import { flowNodes, flowEdges } from '../stores/flowStore'; +import { flowGraphs, allNodes, allEdges, SCENE_GRAPH } from '../stores/flowStore'; import { objectsGroup, lockedObjects, selectedObject, selectedObjects } from '../stores/sceneStore'; import { peers, showToast } from '../stores/appStore'; import { recordTransformSet, recordEntry } from './history'; @@ -106,15 +106,11 @@ function collectParams(group) { if (p.friction != null) map[object.uuid].friction = p.friction; if (p.collider) map[object.uuid].collider = p.collider; }); - const nodes = get(flowNodes); - const edges = get(flowEdges); - edges.forEach((edge) => { - const source = nodes.find((n) => n.id === edge.source); - if (!source || !PHYSICS_TYPES.includes(source.type)) return; - const target = nodes.find((n) => n.id === edge.target); - if (target?.type !== 'objectselector') return; - const uuid = target.data?.selected; - if (!uuid || uuid === '-None-') return; + // H1: physics nodes live in ANY graph (scene or per-object documents) + const nodes = allNodes(); + const edges = allEdges(); + /** apply one physics node's params onto an object's entry @param {any} source @param {string} uuid */ + const applyPhysicsNode = (source, uuid) => { map[uuid] ??= {}; map[uuid].flow = true; // provenance for the Inspector physics list (C1) // flow wiring wins over userData (incl. re-dynamicizing a 'static' object) @@ -134,6 +130,26 @@ function collectParams(group) { // recipe: select the body -> all wheel motors). Joints stay def-owned. if (source.type === 'motor') map[uuid].motor = { vel: source.data?.vel ?? 3, maxForce: source.data?.maxForce ?? 100 }; + }; + edges.forEach((edge) => { + const source = nodes.find((n) => n.id === edge.source); + if (!source || !PHYSICS_TYPES.includes(source.type)) return; + const target = nodes.find((n) => n.id === edge.target); + if (target?.type !== 'objectselector') return; + const uuid = target.data?.selected; + if (!uuid || uuid === '-None-') return; + applyPhysicsNode(source, uuid); + }); + // H1: a physics node inside an OBJECT graph with no explicit selector wiring + // applies to the graph's owner object (matches the runtime's implicit rule) + nodes.forEach((source) => { + if (!PHYSICS_TYPES.includes(source.type)) return; + const graph = source.__graph; + if (!graph || graph === SCENE_GRAPH) return; + const wired = edges.some( + (e) => e.source === source.id && nodes.find((n) => n.id === e.target)?.type === 'objectselector' + ); + if (!wired) applyPhysicsNode(source, graph); }); return map; } @@ -463,7 +479,7 @@ async function startSimulation() { liveSnapshot = snap; applyLiveParams(); }; - liveUnsubs = [flowNodes.subscribe(onGraphChange), flowEdges.subscribe(onGraphChange)]; + liveUnsubs = [flowGraphs.subscribe(onGraphChange)]; // H1: sees every graph simulating.set(true); simPaused.set(false); diff --git a/src/lib/sceneAssets.js b/src/lib/sceneAssets.js index 97af146e..09bae9e8 100644 --- a/src/lib/sceneAssets.js +++ b/src/lib/sceneAssets.js @@ -1,5 +1,5 @@ import { writable, get } from 'svelte/store'; -import { flowNodes } from '../stores/flowStore'; +import { flowGraphs, allNodes } from '../stores/flowStore'; import { objectsGroup } from '../stores/sceneStore'; import { itemByHash } from './explorer'; @@ -19,7 +19,7 @@ function compute() { /** @type {any[]} */ const out = []; const seenAudio = new Set(); - for (const node of get(flowNodes) ?? []) { + for (const node of allNodes()) { // H1: sound/script nodes live in any graph if (node.type === 'sound' && node.data?.hash && !seenAudio.has(node.data.hash)) { seenAudio.add(node.data.hash); out.push({ @@ -69,7 +69,7 @@ function schedule() { export function startSceneAssets() { if (started || typeof window === 'undefined') return; started = true; - flowNodes.subscribe(schedule); + flowGraphs.subscribe(schedule); // H1: any graph document change objectsGroup.subscribe(schedule); // texture changes mutate materials without an objectsGroup identity change // in some paths — a slow safety tick keeps the view honest diff --git a/src/lib/sessions.js b/src/lib/sessions.js index 205195a2..57d3d291 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -1,7 +1,8 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; import { objectsGroup, globalCamera, orbitControls, TControls } from '../stores/sceneStore'; -import { flowNodes, flowEdges } from '../stores/flowStore'; +import { restoreGraphs, clearGraphs, SCENE_GRAPH } from '../stores/flowStore'; +import { serializeGraphs } from './flowGraphs'; import { serializeNode, serializeEdge, sendNodes } from './nodesHandler'; import { parkAnimatedAtBase } from './flowRuntime'; import { peers, showToast } from '../stores/appStore'; @@ -90,6 +91,8 @@ export function buildSessionPayload(name) { // sessions store animation BASE poses, not the current swing (88); // toJSON + thumbnail read the graph synchronously const restore = parkAnimatedAtBase(); + /** @type {any} */ + let graphs; try { return { id: crypto.randomUUID(), @@ -98,8 +101,12 @@ export function buildSessionPayload(name) { count: group?.children.length ?? 0, thumbnail: renderSceneThumbnail(group), objects: (group?.children ?? []).map((/** @type {any} */ child) => child.toJSON()), - nodes: get(flowNodes).map(serializeNode), - edges: get(flowEdges).map(serializeEdge), + // H1: full graph map (+ legacy SCENE fields so old builds can load it) + graphs: (graphs = serializeGraphs(serializeNode, serializeEdge, { + pruneMissing: (uuid) => !group?.getObjectByProperty?.('uuid', uuid) + })), + nodes: graphs[SCENE_GRAPH]?.nodes ?? [], + edges: graphs[SCENE_GRAPH]?.edges ?? [], annotations: annotationsSnapshot(), joints: jointsSnapshot(), camera: camera @@ -333,13 +340,23 @@ export async function applySession(payload) { if (peer) peer.send({ type: 'object', element }); } objectsGroup.update((value) => value); - if (payload.nodes?.length || payload.edges?.length) { - flowNodes.set(payload.nodes ?? []); - flowEdges.set(payload.edges ?? []); - if (peer) { - for (const node of payload.nodes ?? []) peer.send({ type: 'nodecreate', node }); - for (const edge of payload.edges ?? []) peer.send({ type: 'edgecreate', edge }); - } + // H1: new format restores EVERY graph document; legacy payloads carry the + // scene graph only. One 'nodes' snapshot replicates the whole map. + const graphsPayload = + payload.graphs && typeof payload.graphs === 'object' + ? payload.graphs + : payload.nodes?.length || payload.edges?.length + ? { [SCENE_GRAPH]: { nodes: payload.nodes ?? [], edges: payload.edges ?? [] } } + : null; + if (graphsPayload) { + restoreGraphs(graphsPayload); + if (peer) + peer.send({ + type: 'nodes', + graphs: graphsPayload, + nodes: graphsPayload[SCENE_GRAPH]?.nodes ?? [], + edges: graphsPayload[SCENE_GRAPH]?.edges ?? [] + }); } annotationsRestore(payload.annotations ?? []); // P-B: joints restore locally + replicate each def (receivers only apply) @@ -453,8 +470,7 @@ async function stashAndJoin() { object.parent?.remove(object); } objectsGroup.update((value) => value); - flowNodes.set([]); - flowEdges.set([]); + clearGraphs(); // H1: stash empties every graph document showToast('Stashed to Sessions: ' + payload.name); resolveGate(); } diff --git a/src/modules/button/module.js b/src/modules/button/module.js index a45e2472..71fff429 100644 --- a/src/modules/button/module.js +++ b/src/modules/button/module.js @@ -1,7 +1,6 @@ import * as THREE from 'three'; -import { get } from 'svelte/store'; import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; -import { flowNodes } from '../../stores/flowStore'; +import { allNodes } from '../../stores/flowStore'; import { setNodeData } from '$lib/nodesHandler'; import { runtimeNow } from '$lib/moduleSDK'; import ButtonTriggerNode from './ButtonTriggerNode.svelte'; @@ -15,7 +14,8 @@ import ButtonTriggerNode from './ButtonTriggerNode.svelte'; /** Press a trigger node like a viewport click would @param {any} node */ export function pressTriggerNode(node) { const pressed = node.data?.mode === 'push' ? true : !node.data?.pressed; - setNodeData(node.id, { pressed: pressed, at: runtimeNow() }); + // H1: route the data write to the node's own graph (allNodes tags __graph) + setNodeData(node.id, { pressed: pressed, at: runtimeNow() }, node.__graph); } export default { @@ -57,7 +57,8 @@ export default { if (!group) return false; let top = object; while (top.parent && top.parent !== group) top = top.parent; - const node = get(flowNodes).find( + // H1: the trigger node can live in any graph document + const node = allNodes().find( (n) => n.type === 'buttontrigger' && n.data?.button === top.uuid ); if (!node) return false; diff --git a/src/stores/flowStore.js b/src/stores/flowStore.js index 1fad345c..86961931 100644 --- a/src/stores/flowStore.js +++ b/src/stores/flowStore.js @@ -1,6 +1,23 @@ -import { writable } from 'svelte/store'; +import { writable, get } from 'svelte/store'; -// Shared node graph state, replicated between peers +// Shared node graph state, replicated between peers. +// +// H1 (flow v2): graphs are PER-OBJECT documents. `flowGraphs` is the source of +// truth -- a map keyed 'scene' | objectUuid, each entry {nodes, edges}. The +// legacy `flowNodes`/`flowEdges` stores remain as the ACTIVE graph's live VIEW +// (what the xyflow editor binds to); the mirror below keeps the two in sync. +// Code that needs the whole world (runtime, physics, serializers) reads +// flowGraphs; editor-scoped code keeps reading flowNodes/flowEdges. + +export const SCENE_GRAPH = 'scene'; + +/** @type {import('svelte/store').Writable>} */ +export const flowGraphs = writable({ [SCENE_GRAPH]: { nodes: [], edges: [] } }); + +/** The graph the editor is currently showing ('scene' or an object uuid -- the + * uuid may have NO graph yet: the editor then shows the create-flow empty state). */ +/** @type {import('svelte/store').Writable} */ +export const activeGraphId = writable(SCENE_GRAPH); /** @type {import('svelte/store').Writable} */ export const flowNodes = writable([]); @@ -8,11 +25,178 @@ export const flowNodes = writable([]); /** @type {import('svelte/store').Writable} */ export const flowEdges = writable([]); +// --- the view <-> store mirror (lives HERE, in the leaf store module, so the +// runtime/physics can consume flowGraphs without importing a module that pulls +// in history/peers -- history.js statically imports flowRuntime, and a +// flowRuntime -> flowGraphs -> history edge would close a TDZ cycle) ---------- + +let mirroring = false; +let mirrorStarted = false; + +/** Read one graph document. @param {string} graphId */ +export function graphOf(graphId) { + const all = get(flowGraphs); + return all?.[graphId] ?? null; +} + +/** True when this graph document exists. @param {string} id */ +export function graphExists(id) { + return !!graphOf(id); +} + +/** @param {string} graphId */ +function pushViewFromGraph(graphId) { + const graph = graphOf(graphId) ?? { nodes: [], edges: [] }; + mirroring = true; + flowNodes.set(graph.nodes); + flowEdges.set(graph.edges); + mirroring = false; +} + +/** Start the editor-view mirror (idempotent; wired from startFlowRuntime). */ +export function startGraphMirror() { + if (mirrorStarted || typeof window === 'undefined') return; + mirrorStarted = true; + // view -> store: editor edits (xyflow bind mutations, FlowCode applies, legacy + // direct-view writers) land in the ACTIVE graph. An object view with no graph + // document stays view-only (the empty state) -- documents are only born + // through createObjectGraph (flowGraphs.js). + flowNodes.subscribe((nodes) => { + if (mirroring) return; + const id = get(activeGraphId); + flowGraphs.update((all) => { + if (id !== SCENE_GRAPH && !all[id]) return all; + return { ...all, [id]: { nodes, edges: all[id]?.edges ?? [] } }; + }); + }); + flowEdges.subscribe((edges) => { + if (mirroring) return; + const id = get(activeGraphId); + flowGraphs.update((all) => { + if (id !== SCENE_GRAPH && !all[id]) return all; + return { ...all, [id]: { nodes: all[id]?.nodes ?? [], edges } }; + }); + }); + pushViewFromGraph(SCENE_GRAPH); +} + +/** + * Switch the editor to a graph. `id` may be an object uuid WITHOUT a graph -- + * the editor then shows the create-flow empty state over an empty view. + * @param {string} id + */ +export function setActiveGraph(id) { + const current = get(activeGraphId); + if (current === id) return; + activeGraphId.set(id); + pushViewFromGraph(id); +} + +/** Re-push the active graph into the view (after out-of-band store writes). */ +export function refreshActiveView() { + pushViewFromGraph(get(activeGraphId)); +} + +/** + * Mutate one graph document (remote appliers use this so edits to NON-active + * graphs apply without disturbing the editor). Mirrors into the view when the + * mutated graph is the active one. Creates the document if missing. + * @param {string} graphId + * @param {(graph: {nodes: any[], edges: any[]}) => {nodes: any[], edges: any[]}} fn + */ +export function updateGraph(graphId, fn) { + flowGraphs.update((all) => { + const graph = all[graphId] ?? { nodes: [], edges: [] }; + return { ...all, [graphId]: fn(graph) }; + }); + if (get(activeGraphId) === graphId) pushViewFromGraph(graphId); +} + +/** Remove a graph document (lifecycle wrappers in flowGraphs.js replicate/record). + * @param {string} graphId */ +export function removeGraphDocument(graphId) { + if (graphId === SCENE_GRAPH) return; + flowGraphs.update((all) => { + if (!all[graphId]) return all; + const next = { ...all }; + delete next[graphId]; + return next; + }); + if (get(activeGraphId) === graphId) pushViewFromGraph(graphId); +} + +/** + * All nodes across every graph, each tagged with a runtime-only `__graph` + * field (never serialized -- serializeNode copies explicit fields only). The + * runtime uses the tag for implicit-owner targeting in object graphs. + */ +export function allNodes() { + const all = get(flowGraphs); + const out = []; + for (const [graphId, graph] of Object.entries(all ?? {})) { + for (const node of graph.nodes) { + if (node.__graph !== graphId) node.__graph = graphId; + out.push(node); + } + } + return out; +} + +/** Find a node in ANY graph. @param {(n: any) => boolean} pred + * @returns {{node: any, graphId: string} | null} */ +export function findNodeAnyGraph(pred) { + const all = get(flowGraphs); + for (const [graphId, graph] of Object.entries(all ?? {})) { + const node = graph.nodes.find(pred); + if (node) return { node, graphId }; + } + return null; +} + +/** All edges across every graph. */ +export function allEdges() { + const all = get(flowGraphs); + const out = []; + for (const graph of Object.values(all ?? {})) out.push(...graph.edges); + return out; +} + +/** nodes+edges count across every graph (nodesync drift heal). */ +export function graphTotals() { + const all = get(flowGraphs); + let count = 0; + for (const graph of Object.values(all ?? {})) count += graph.nodes.length + graph.edges.length; + return count; +} + +/** + * Replace all graph documents (session/autosave restore). Resets the editor to + * the scene graph. + * @param {Record} graphs + */ +export function restoreGraphs(graphs) { + /** @type {Record} */ + const next = { [SCENE_GRAPH]: { nodes: [], edges: [] } }; + for (const [graphId, graph] of Object.entries(graphs ?? {})) { + next[graphId] = { nodes: graph.nodes ?? [], edges: graph.edges ?? [] }; + } + flowGraphs.set(next); + activeGraphId.set(SCENE_GRAPH); + pushViewFromGraph(SCENE_GRAPH); +} + +/** Empty every graph (clear scene). */ +export function clearGraphs() { + flowGraphs.set({ [SCENE_GRAPH]: { nodes: [], edges: [] } }); + activeGraphId.set(SCENE_GRAPH); + pushViewFromGraph(SCENE_GRAPH); +} + // scene object uuids whose flow effects (animations/colors) are muted locally /** @type {import('svelte/store').Writable} */ export const mutedFlowObjects = writable([]); -// live output value of each value/logic node (133), for the on-card readouts — +// live output value of each value/logic node (133), for the on-card readouts -- // the runtime writes it ~6/s; nodeId -> number | boolean | [x,y,z] | string /** @type {import('svelte/store').Writable>} */ export const flowValues = writable({}); @@ -23,7 +207,7 @@ export const flowValues = writable({}); /** @type {import('svelte/store').Writable>} */ export const flowTriggers = writable({}); -// live peer cursors in the flow editor: peerId -> { x, y, name, ts } (flow coordinates) +// live peer cursors in the flow editor: peerId -> { x, y, name, ts, graphId } /** @type {import('svelte/store').Writable>} */ export const flowCursors = writable({}); diff --git a/tests/e2e/flow-object-graphs.test.cjs b/tests/e2e/flow-object-graphs.test.cjs new file mode 100644 index 00000000..863bfb90 --- /dev/null +++ b/tests/e2e/flow-object-graphs.test.cjs @@ -0,0 +1,119 @@ +// Roadmap #13 H1 — per-object flow graphs. +// - flowGraphs is the source of truth ('scene' + objectUuid documents); the +// editor view mirrors the ACTIVE graph +// - selecting an object (with the editor open) switches scope; no-flow objects +// show the empty state with a one-click Create flow; delete asks to confirm +// - an effect node inside an object graph implicitly animates its OWNER +// - object graphs replicate: graphcreate + graph-tagged node edits reach peers +// - undo restores a deleted flow (the 'flowgraph' history kind) +const h = require('./helpers.cjs'); + +const graphsOf = (peer) => + peer.page.evaluate(() => new Promise((r) => window.__stores.flowGraphs.subscribe((g) => r(Object.keys(g)))())); +const activeOf = (peer) => + peer.page.evaluate(() => new Promise((r) => window.__stores.activeGraphId.subscribe((v) => r(v))())); +const nodesIn = (peer, graphId) => + peer.page.evaluate( + (id) => new Promise((r) => window.__stores.flowGraphs.subscribe((g) => r(g[id]?.nodes?.length ?? -1))()), + graphId + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // --- baseline: the scene graph exists and is active ----------------------- + h.check((await graphsOf(A)).includes('scene'), 'scene graph document exists at boot'); + h.check((await activeOf(A)) === 'scene', 'editor scope starts on the scene graph'); + + // --- create a box + open the flow editor ---------------------------------- + const uuid = await A.page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create box'); + const g = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const box = g.children[g.children.length - 1]; + window.__stores.flowGraphClose.set(false); // open the docked flow editor + return box.uuid; + }); + await A.page.waitForTimeout(600); + + // --- selecting the object switches scope + shows the empty state ---------- + await A.page.evaluate((id) => window.__stores.objectActions.selectObject(id), uuid); + await A.page.waitForTimeout(400); + h.check((await activeOf(A)) === uuid, 'selecting the object switches the editor scope to it'); + h.check(await A.page.locator('#flow-empty-state').first().isVisible(), 'no-flow object shows the empty state'); + + // --- one-click create ------------------------------------------------------ + await A.page.locator('#flow-create-btn').click(); + await A.page.waitForTimeout(300); + h.check((await graphsOf(A)).includes(uuid), 'Create flow makes the object graph document'); + h.check((await A.page.locator('#flow-empty-state').count()) === 0, 'empty state clears after create'); + const chip = await A.page.locator('#flow-scope-chip').innerText(); + h.check(chip.includes('object flow'), 'scope chip labels the object flow'); + + // --- an effect node in the object graph drives the OWNER implicitly ------- + await A.page.evaluate((id) => { + window.__stores.nodesHandler.createFlowNode( + { id: 'spin-h1', type: 'spin', position: { x: 0, y: 0 }, data: { type: 'spin', axis: 'y', speed: 3 } }, + id + ); + }, uuid); + await h.eventually( + () => A.page.evaluate((id) => window.__stores.flowRuntime.isAnimatedTarget(id), uuid), + (v) => v === true, + 'implicit-owner: a spin node in the object graph animates the object (no selector wired)' + ); + + // --- deselect returns to the scene graph ---------------------------------- + await A.page.evaluate(() => window.__stores.selectedObject.set([])); + await A.page.waitForTimeout(400); + h.check((await activeOf(A)) === 'scene', 'deselecting returns the editor to the scene graph'); + + // --- two-peer: the object graph replicates -------------------------------- + const B = await h.setupPage(browser, 'B'); + await h.connect(A, B); + await h.eventually( + () => nodesIn(B, uuid), + (n) => n === 1, + 'late joiner receives the object graph (handshake full-state)' + ); + + // a graph-tagged live edit reaches the peer + await A.page.evaluate((id) => { + const node = { id: 'spin-h1-b', type: 'spin', position: { x: 40, y: 40 }, data: { type: 'spin', axis: 'y', speed: 1 } }; + window.__stores.nodesHandler.createFlowNode(node, id); + let peer; window.__stores.peers.subscribe((p) => (peer = p))(); + peer.send({ type: 'nodecreate', node, graphId: id }); + }, uuid); + await h.eventually( + () => nodesIn(B, uuid), + (n) => n === 2, + 'graph-tagged nodecreate lands in the peer object graph' + ); + + // --- delete flow with confirmation + undo restores ------------------------ + await A.page.evaluate((id) => window.__stores.objectActions.selectObject(id), uuid); + await A.page.waitForTimeout(300); + await A.page.locator('#flow-scope-delete').click(); + await A.page.waitForTimeout(300); + h.check(await A.page.getByRole('button', { name: 'Delete flow' }).first().isVisible(), 'delete asks for confirmation'); + await A.page.getByRole('button', { name: 'Delete flow' }).click(); + await A.page.waitForTimeout(400); + h.check(!(await graphsOf(A)).includes(uuid), 'confirming removes the object graph'); + await h.eventually( + () => graphsOf(B).then((g) => g.includes(uuid)), + (v) => v === false, + 'graph deletion replicates to the peer' + ); + + await A.page.evaluate(() => window.__stores.history.undo()); + await A.page.waitForTimeout(400); + h.check((await graphsOf(A)).includes(uuid), 'undo restores the deleted flow'); + h.check((await nodesIn(A, uuid)) === 2, 'undo restores the flow CONTENT'); + await h.eventually( + () => nodesIn(B, uuid), + (n) => n === 2, + 'restored flow replicates back to the peer' + ); + + await h.finish(browser); +}); From e4275260d1bd5c79913c2d80703c81fa92e90a3f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 21 Jul 2026 23:49:47 +0300 Subject: [PATCH 2/9] [feat] flow v2 H5 - object flows as scene-graph nodes - Flow Input / Flow Output interface nodes DECLARE an object flow's public sockets (name + type; number/boolean/vector3/color). Inside the graph a Flow Input is a value source (fed by the scene, else its fallback) and a Flow Output surfaces its wired value. - Object Flow node embeds a flow into the SCENE graph with the declared sockets: wired scene values inject into the flow's Flow Inputs each tick (same tick); Flow Output values are harvested at tick end and read by the scene on the NEXT tick (one frame of latency, by design). Multi-output values travel as a __handles map unwrapped by sourceHandle in input()/ resolveInputs. - Two entry points: the node palette/pane menu (Object Flow group, picker lists only objects WITH flows) and the object context menu "Add flow to Scene graph" (embed-once per graph v1; drag-from-object-list deferred). - Determinism invariants: renaming/retyping/deleting interface nodes prunes stale embed edges on EVERY peer (pure function of graph state - watcher on the interface signature + the applyNodesSnapshot post-pass, the customNodes precedent); deleting a flow removes its embedded nodes + edges on both the local and applier paths. - Typed sockets: Socket.svelte gains forceType (data-declared types); isValidFlowConnection resolves flowinput vtype / embed handles from the referenced graph; flow outputs accept any value type. - e2e: flow-object-embed (7 checks, all pass - incl. the scene->flow->scene value round-trip and prune-on-rename); flow-object-graphs regression all pass; build green; svelte-check 501/77 held. Co-Authored-By: Claude Fable 5 --- src/App.svelte | 7 +- src/components/editors/Nodes.svelte | 5 + .../editors/nodes/FlowIONode.svelte | 57 ++++++ .../editors/nodes/ObjectFlowNode.svelte | 82 +++++++++ src/components/editors/nodes/Socket.svelte | 5 +- src/lib/flowGraphs.js | 3 + src/lib/flowRuntime.js | 91 ++++++++-- src/lib/flowSockets.js | 21 ++- src/lib/nodeCatalog.js | 11 ++ src/lib/nodesHandler.js | 2 + src/lib/objectFlow.js | 170 ++++++++++++++++++ src/lib/objectMenu.js | 16 ++ tests/e2e/flow-object-embed.test.cjs | 93 ++++++++++ 13 files changed, 548 insertions(+), 15 deletions(-) create mode 100644 src/components/editors/nodes/FlowIONode.svelte create mode 100644 src/components/editors/nodes/ObjectFlowNode.svelte create mode 100644 src/lib/objectFlow.js create mode 100644 tests/e2e/flow-object-embed.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 5106ff22..bb578a6d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -138,9 +138,10 @@ import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), import('./lib/ai/meshJobs'), - import('./lib/flowGraphs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib } + import('./lib/flowGraphs'), + import('./lib/objectFlow') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib } }) } }) diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index cdb60cde..31fc1bf5 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -41,6 +41,8 @@ import EffectNode from './nodes/EffectNode.svelte'; import OnClickNode from './nodes/OnClickNode.svelte'; import CounterNode from './nodes/CounterNode.svelte'; + import FlowIONode from './nodes/FlowIONode.svelte'; + import ObjectFlowNode from './nodes/ObjectFlowNode.svelte'; import { flowNodes as nodes, flowEdges as edges, customNodeDefs, nodeDesignerOpen, flowGraphs, activeGraphId, SCENE_GRAPH, setActiveGraph } from '../../stores/flowStore'; import { createObjectGraph, requestDeleteObjectGraph } from '$lib/flowGraphs'; import { objectsGroup, selectedObject } from '../../stores/sceneStore'; @@ -99,6 +101,9 @@ visibility: EffectNode, onclick: OnClickNode, counter: CounterNode, + flowinput: FlowIONode, + flowoutput: FlowIONode, + objectflow: ObjectFlowNode, ...moduleTypes }; diff --git a/src/components/editors/nodes/FlowIONode.svelte b/src/components/editors/nodes/FlowIONode.svelte new file mode 100644 index 00000000..40331eb2 --- /dev/null +++ b/src/components/editors/nodes/FlowIONode.svelte @@ -0,0 +1,57 @@ + + + + {#if data.type === 'flowinput'} + + {:else} + + {/if} + + {#if data.type === 'flowinput'} + + + {/if} + diff --git a/src/components/editors/nodes/ObjectFlowNode.svelte b/src/components/editors/nodes/ObjectFlowNode.svelte new file mode 100644 index 00000000..f41ee0be --- /dev/null +++ b/src/components/editors/nodes/ObjectFlowNode.svelte @@ -0,0 +1,82 @@ + + + + + {#each iface.inputs as socket, i (socket.name)} + + {/each} + {#each iface.outputs as socket, i (socket.name)} + + {/each} +
+ {#each iface.inputs as socket (socket.name)} +
â–¸ {socket.name}
+ {/each} + {#each iface.outputs as socket (socket.name)} +
{socket.name} â–¸
+ {/each} + {#if data.flowUuid && !iface.inputs.length && !iface.outputs.length} +
no Flow Input/Output declared
+ {/if} +
+
diff --git a/src/components/editors/nodes/Socket.svelte b/src/components/editors/nodes/Socket.svelte index d9b5e916..dde1f5fc 100644 --- a/src/components/editors/nodes/Socket.svelte +++ b/src/components/editors/nodes/Socket.svelte @@ -12,10 +12,13 @@ export let position: any = undefined; // defaults by kind export let top: number | undefined = undefined; // px offset for stacked targets export let style: string = ''; + // H5: interface sockets carry a DATA-declared type (flowinput.vtype), not a + // table lookup — an explicit type wins when provided + export let forceType: string | undefined = undefined; const RIGHT = Position.Right; const LEFT = Position.Left; - $: socketType = kind === 'source' ? outputType(nodeType) : inputType(nodeType, id ?? 'a'); + $: socketType = forceType ?? (kind === 'source' ? outputType(nodeType) : inputType(nodeType, id ?? 'a')); $: pos = position ?? (kind === 'source' ? RIGHT : LEFT); $: css = `--socket-color: ${typeColor(socketType)};${top !== undefined ? ` top: ${top}px;` : ''}${style}`; diff --git a/src/lib/flowGraphs.js b/src/lib/flowGraphs.js index 3f3eec13..4da1d649 100644 --- a/src/lib/flowGraphs.js +++ b/src/lib/flowGraphs.js @@ -9,6 +9,7 @@ import { } from '../stores/flowStore'; import { peers, showToast } from '../stores/appStore'; import { registerHistoryKind, recordEntry } from './history'; +import { removeEmbedsOf } from './objectFlow'; // H1 (flow v2): object-flow lifecycle -- create/delete graph documents, // replicated (graphcreate/graphdelete) and undoable (the 'flowgraph' history @@ -55,6 +56,7 @@ export function deleteObjectGraph(uuid, opts = {}) { after: 'after' }); removeGraphDocument(uuid); + removeEmbedsOf(uuid); // H5: embedded Object Flow nodes die with their flow /** @type {any} */ const peer = get(peers); if (replicate && peer) peer.send({ type: 'graphdelete', uuid }); @@ -69,6 +71,7 @@ export function applyGraphCreate(uuid) { /** @param {string} uuid */ export function applyGraphDelete(uuid) { removeGraphDocument(uuid); + removeEmbedsOf(uuid); // deterministic on the applier side too } // 'flowgraph' history kind: undo of create removes the graph; undo of delete diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index f356eb09..f6554f2a 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -8,6 +8,7 @@ import { moduleEffects, moduleFrameTasks } from './moduleSDK'; import { runScript } from './scriptRuntime'; import { findNodeDef } from './customNodes'; import { updateSounds } from './soundRuntime'; +import { startObjectFlowWatcher } from './objectFlow'; // Runs the node graph: applies colorpicker->objectselector colors on graph changes // and drives animation/effect nodes with a requestAnimationFrame loop. @@ -170,8 +171,35 @@ export function notifyExternalMove(uuid) { export const valueTypes = [ 'number', 'vector3', 'toggle', 'random', 'time', 'math', 'compare', 'gate', 'loop', 'timer', 'distance', 'proximity', 'onclick', 'counter', // 134 - 'maprange', 'select' // 4.6 + 'maprange', 'select', // 4.6 + 'flowinput', 'flowoutput', 'objectflow' // H5: object-flow composition ]; + +// --- H5: object flows embedded in the scene graph ----------------------------- +// The SCENE graph feeds values INTO an object flow through its declared Flow +// Input nodes (per-tick injection, same tick) and reads its Flow Output values +// back (computed at the END of a tick, consumed by the scene on the NEXT tick — +// one frame of latency, documented in the plan). +/** @type {Record>} graphId -> {inputName: value} */ +let graphInputs = {}; +/** @type {Record>} graphId -> {outputName: value} */ +let graphOutputs = {}; + +/** Unwrap a multi-output node's handle map by the edge's sourceHandle. + * @param {any} value @param {any} edge */ +function unwrapHandle(value, edge) { + if (value && typeof value === 'object' && value.__handles) + return edge?.sourceHandle ? value.__handles[edge.sourceHandle] : undefined; + return value; +} + +/** Typed zero for a Flow Input with nothing injected. @param {string} vtype */ +function typedFallback(vtype) { + if (vtype === 'boolean') return false; + if (vtype === 'vector3') return [0, 0, 0]; + if (vtype === 'color') return '#ffffff'; + return 0; +} // existing input sources that also expose a value on their output handle // (4.4: switcher outputs its selected index) const sourceValueTypes = ['slider', 'colorpicker', 'objectselector', 'switcher']; @@ -237,13 +265,9 @@ function evalNodeBody(node, allNodes, allEdges, time, seen, ctx) { const input = (handle, fallback) => { const edge = allEdges.find((e) => e.target === node.id && e.targetHandle === handle); if (edge) { - const value = evalNode( - allNodes.find((n) => n.id === edge.source), - allNodes, - allEdges, - time, - seen, - ctx + const value = unwrapHandle( + evalNode(allNodes.find((n) => n.id === edge.source), allNodes, allEdges, time, seen, ctx), + edge ); if (value !== undefined) return value; } @@ -385,6 +409,20 @@ function evalNodeBody(node, allNodes, allEdges, time, seen, ctx) { } case 'counter': return ctx && ctx.triggers && ctx.triggers[node.id] ? ctx.triggers[node.id].count : 0; + // --- H5: object-flow composition --- + case 'flowinput': { + // value injected by the scene graph's embedded Object Flow node this + // tick; falls back to the node's own default param + const injected = graphInputs[node.__graph]?.[d.name ?? 'value']; + return injected !== undefined ? injected : d.fallback ?? typedFallback(d.vtype); + } + case 'flowoutput': + // a Flow Output IS its wired input (lets the tick + readouts reuse eval) + return input('value', d.fallback ?? 0); + case 'objectflow': + // the embedded node exposes the target flow's outputs as named handles, + // computed at the END of the previous tick (one-frame latency) + return { __handles: graphOutputs[d.flowUuid] ?? {} }; default: return undefined; } @@ -403,7 +441,7 @@ export function resolveInputs(node, allNodes, allEdges, time, ctx = null) { const source = allNodes.find((n) => n.id === edge.source); if (!source) return; if (!valueTypes.includes(source.type) && !sourceValueTypes.includes(source.type)) return; - const value = evalNode(source, allNodes, allEdges, time, new Set(), ctx); + const value = unwrapHandle(evalNode(source, allNodes, allEdges, time, new Set(), ctx), edge); if (value !== undefined) data[edge.targetHandle] = value; }); return data; @@ -596,6 +634,25 @@ function tick(now) { const ctx = runtimeCtx(); // 134: scene + trigger state for the evaluators // collect active animations per scene object + // H5: inject the scene graph's wired values into each embedded object flow + // BEFORE effects run, so Flow Inputs read this tick's scene values + /** @type {Record>} */ + const nextInputs = {}; + nodes.forEach((embed) => { + if (embed.type !== 'objectflow') return; + const target = embed.data?.flowUuid; + if (!target) return; + const bucket = nextInputs[target] ?? (nextInputs[target] = {}); + edges.forEach((e) => { + if (e.target !== embed.id || !e.targetHandle) return; + const src = nodes.find((n) => n.id === e.source); + if (!src) return; + const v = unwrapHandle(evalNode(src, nodes, edges, time, new Set(), ctx), e); + if (v !== undefined) bucket[e.targetHandle] = v; + }); + }); + graphInputs = nextInputs; + const active = new Map(); // uuid -> anim nodes /** @param {any} node */ const isEffectNode = (node) => @@ -669,11 +726,24 @@ function tick(now) { /** @type {Record} */ const values = {}; for (const node of nodes) { - if (valueTypes.includes(node.type)) values[node.id] = evalNode(node, nodes, edges, time, new Set(), ctx); + // H5: objectflow returns a handle MAP, not a scalar — no card readout + if (valueTypes.includes(node.type) && node.type !== 'objectflow') + values[node.id] = evalNode(node, nodes, edges, time, new Set(), ctx); } flowValues.set(values); } + // H5: harvest every object flow's declared outputs for the NEXT tick's + // embedded Object Flow reads (one-frame latency by design) + /** @type {Record>} */ + const nextOutputs = {}; + nodes.forEach((node) => { + if (node.type !== 'flowoutput' || !node.__graph || node.__graph === SCENE_GRAPH) return; + const name = node.data?.name ?? 'out'; + (nextOutputs[node.__graph] ??= {})[name] = evalNode(node, nodes, edges, time, new Set(), ctx); + }); + graphOutputs = nextOutputs; + moduleFrameTasks.forEach((task) => { try { task(time); @@ -714,6 +784,7 @@ export function startFlowRuntime() { // combined node/edge set; nodes carry a runtime-only __graph tag used for // implicit-owner targeting. The mirror keeps the editor view in sync. startGraphMirror(); + startObjectFlowWatcher(); // H5: embed-socket pruning on interface changes flowGraphs.subscribe(() => { nodes = allNodes(); edges = allEdges(); diff --git a/src/lib/flowSockets.js b/src/lib/flowSockets.js index 9174e29f..a1c8e487 100644 --- a/src/lib/flowSockets.js +++ b/src/lib/flowSockets.js @@ -4,6 +4,8 @@ // effect/anim/action nodes carry the special 'effect' type into an Object // Selector. Existing saved edges are NOT re-validated — only new drags. +import { graphOf } from '../stores/flowStore'; + /** output type of a node's source handle @type {Record} */ const OUTPUT = { number: 'number', slider: 'number', time: 'number', loop: 'number', timer: 'number', @@ -97,5 +99,22 @@ export function isValidFlowConnection(connection, nodes) { const source = nodes.find((n) => n.id === connection.source); const target = nodes.find((n) => n.id === connection.target); if (!source || !target) return false; - return canConnect(outputType(source.type), inputType(target.type, connection.targetHandle)); + // H5: the object-flow interface types come from node DATA / the referenced + // graph's declarations, not the static type table + const from = source.type === 'flowinput' ? source.data?.vtype ?? 'number' : outputType(source.type); + if (source.type === 'objectflow') { + // embedded outputs carry whatever the flow's outputs compute — untyped v1, + // anything except the effect channel may consume them + return inputType(target.type, connection.targetHandle) !== 'effect'; + } + if (target.type === 'flowoutput') return from !== 'effect'; // outputs accept any value + if (target.type === 'objectflow') { + const graph = graphOf(target.data?.flowUuid ?? ''); + const decl = graph?.nodes.find( + (/** @type {any} */ n) => + n.type === 'flowinput' && (n.data?.name ?? 'value') === connection.targetHandle + ); + return canConnect(from, decl?.data?.vtype ?? 'number'); + } + return canConnect(from, inputType(target.type, connection.targetHandle)); } diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index 4f1e88a4..795c458d 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -31,6 +31,17 @@ export const nodeCatalog = [ group: 'Scene', items: [{ type: 'objectselector', label: 'Object Selector', defaults: { selected: '-None-' } }] }, + { + // H5: object-flow composition — Flow Input/Output DECLARE an object flow's + // public sockets; Object Flow embeds a flow into the scene graph with those + // sockets. Interface nodes only mean something inside an object flow. + group: 'Object Flow', + items: [ + { type: 'flowinput', label: 'Flow Input', defaults: { name: 'value', vtype: 'number', fallback: 0 } }, + { type: 'flowoutput', label: 'Flow Output', defaults: { name: 'out', fallback: 0 } }, + { type: 'objectflow', label: 'Object Flow', defaults: { flowUuid: '' } } + ] + }, { group: 'Logic', items: [ diff --git a/src/lib/nodesHandler.js b/src/lib/nodesHandler.js index d5f840b5..09afc402 100644 --- a/src/lib/nodesHandler.js +++ b/src/lib/nodesHandler.js @@ -161,6 +161,8 @@ export function applyNodesSnapshot(nodes, edges, graphs) { // B4.5: a stale snapshot must not resurrect edges into removed custom-node // params -- prune deterministically after every snapshot apply import('./customNodes').then((m) => m.pruneAllCustomNodeEdges()); + // H5: same invariant for embedded Object Flow sockets + import('./objectFlow').then((m) => m.pruneObjectFlowEdges()); } // --- Drift detection: peers periodically exchange a graph hash and pull a diff --git a/src/lib/objectFlow.js b/src/lib/objectFlow.js new file mode 100644 index 00000000..02caeea8 --- /dev/null +++ b/src/lib/objectFlow.js @@ -0,0 +1,170 @@ +import { get } from 'svelte/store'; +import { + flowGraphs, + graphOf, + updateGraph, + SCENE_GRAPH, + graphExists +} from '../stores/flowStore'; +import { peers } from '../stores/appStore'; + +// H5 (flow v2): object flows embedded in the SCENE graph as `objectflow` nodes. +// The embedded node's sockets are DECLARED by Flow Input / Flow Output interface +// nodes inside the object flow; this module derives that interface, keeps scene +// edges pruned when the interface changes (deterministic applier-side invariant, +// the customNodes precedent), and removes embeds when their flow dies. + +/** + * The declared public interface of an object flow. + * @param {string} graphId + * @returns {{inputs: {name: string, vtype: string}[], outputs: {name: string}[]}} + */ +export function interfaceOf(graphId) { + const graph = graphOf(graphId); + /** @type {{name: string, vtype: string}[]} */ + const inputs = []; + /** @type {{name: string}[]} */ + const outputs = []; + if (graph) { + const seenIn = new Set(); + const seenOut = new Set(); + for (const node of graph.nodes) { + if (node.type === 'flowinput') { + const name = node.data?.name ?? 'value'; + if (!seenIn.has(name)) { + seenIn.add(name); + inputs.push({ name, vtype: node.data?.vtype ?? 'number' }); + } + } else if (node.type === 'flowoutput') { + const name = node.data?.name ?? 'out'; + if (!seenOut.has(name)) { + seenOut.add(name); + outputs.push({ name }); + } + } + } + } + return { inputs, outputs }; +} + +/** Objects that HAVE a flow document (for the embed node's picker). */ +export function graphsAvailableToEmbed() { + return Object.keys(get(flowGraphs)).filter((id) => id !== SCENE_GRAPH); +} + +/** + * Prune edges wired to embed sockets that the target flow no longer declares + * (renamed/retyped/deleted interface nodes). Pure function of graph state — + * idempotent and applied identically on every peer, never broadcast. + */ +export function pruneObjectFlowEdges() { + const all = get(flowGraphs); + for (const [graphId, graph] of Object.entries(all)) { + /** @type {Set} */ + const bad = new Set(); + for (const node of graph.nodes) { + if (node.type !== 'objectflow') continue; + const iface = interfaceOf(node.data?.flowUuid ?? ''); + const inNames = new Set(iface.inputs.map((i) => i.name)); + const outNames = new Set(iface.outputs.map((o) => o.name)); + for (const edge of graph.edges) { + if (edge.target === node.id && edge.targetHandle && !inNames.has(edge.targetHandle)) + bad.add(edge.id); + if (edge.source === node.id && edge.sourceHandle && !outNames.has(edge.sourceHandle)) + bad.add(edge.id); + } + } + if (bad.size) + updateGraph(graphId, (g) => ({ + nodes: g.nodes, + edges: g.edges.filter((e) => !bad.has(e.id)) + })); + } +} + +/** + * Remove every embedded node referencing a flow (called when the flow is + * deleted, on BOTH the local and applier paths — deterministic cleanup). + * @param {string} flowUuid + */ +export function removeEmbedsOf(flowUuid) { + const all = get(flowGraphs); + for (const [graphId, graph] of Object.entries(all)) { + const ids = graph.nodes + .filter((n) => n.type === 'objectflow' && n.data?.flowUuid === flowUuid) + .map((n) => n.id); + if (!ids.length) continue; + updateGraph(graphId, (g) => ({ + nodes: g.nodes.filter((n) => !ids.includes(n.id)), + edges: g.edges.filter((e) => !ids.includes(e.source) && !ids.includes(e.target)) + })); + } +} + +/** + * Context-menu entry point: drop an object's flow into the SCENE graph as an + * embedded node (replicated like any editor-created node). + * @param {string} flowUuid @param {string} [label] object name for the card + */ +export function addObjectFlowToScene(flowUuid, label) { + if (!graphExists(flowUuid)) return false; + const scene = graphOf(SCENE_GRAPH); + // embed once per graph (v1) — jump duplicates instead of stacking them + if (scene?.nodes.some((n) => n.type === 'objectflow' && n.data?.flowUuid === flowUuid)) + return false; + const node = { + id: crypto.randomUUID(), + type: 'objectflow', + position: { x: 80 + Math.floor(Math.random() * 40), y: 80 + Math.floor(Math.random() * 40) }, + data: { type: 'objectflow', label: label || 'Object Flow', flowUuid }, + class: 'w-[170px]' + }; + updateGraph(SCENE_GRAPH, (g) => ({ nodes: [...g.nodes, node], edges: g.edges })); + /** @type {any} */ + const peer = get(peers); + if (peer) { + peer.send({ + type: 'nodecreate', + node: { + id: node.id, + type: node.type, + position: node.position, + data: { ...node.data }, + class: node.class + }, + graphId: SCENE_GRAPH + }); + } + return true; +} + +// --- interface-change watcher -------------------------------------------------- + +let watcherStarted = false; +let lastSignature = ''; + +/** Signature of every graph's declared interface (names + types). */ +function interfaceSignature() { + const all = get(flowGraphs); + /** @type {any[]} */ + const parts = []; + for (const graphId of Object.keys(all).sort()) { + if (graphId === SCENE_GRAPH) continue; + const iface = interfaceOf(graphId); + if (iface.inputs.length || iface.outputs.length) parts.push([graphId, iface]); + } + return JSON.stringify(parts); +} + +/** Re-prune embed edges whenever any flow's declared interface changes. */ +export function startObjectFlowWatcher() { + if (watcherStarted || typeof window === 'undefined') return; + watcherStarted = true; + lastSignature = interfaceSignature(); + flowGraphs.subscribe(() => { + const signature = interfaceSignature(); + if (signature === lastSignature) return; + lastSignature = signature; + pruneObjectFlowEdges(); + }); +} diff --git a/src/lib/objectMenu.js b/src/lib/objectMenu.js index 63a8ac50..9775fd52 100644 --- a/src/lib/objectMenu.js +++ b/src/lib/objectMenu.js @@ -173,6 +173,22 @@ export function buildObjectMenuItems(uuid, opts = {}) { ) ) }, + { + // H5: embed this object's flow into the SCENE graph as an Object Flow node + label: 'Add flow to Scene graph', + tooltip: 'Embed this object’s flow as a node with its declared inputs/outputs', + action: () => + Promise.all([import('./objectFlow'), import('../stores/flowStore'), import('../stores/appStore')]).then( + ([objectFlow, flowStore, appStore]) => { + if (!flowStore.graphExists(uuid)) { + appStore.showToast('This object has no flow yet — select it in the Flow editor and click Create flow.'); + return; + } + const added = objectFlow.addObjectFlowToScene(uuid, object?.name || object?.type); + appStore.showToast(added ? 'Object Flow node added to the Scene graph' : 'This flow is already embedded in the Scene graph'); + } + ) + }, { label: 'Delete' + suffix, danger: true, diff --git a/tests/e2e/flow-object-embed.test.cjs b/tests/e2e/flow-object-embed.test.cjs new file mode 100644 index 00000000..98bf1ac3 --- /dev/null +++ b/tests/e2e/flow-object-embed.test.cjs @@ -0,0 +1,93 @@ +// Roadmap #13 H5 — object flows embedded in the scene graph. +// - Flow Input/Output nodes inside an object flow DECLARE its sockets +// - an Object Flow node in the SCENE graph carries those sockets: scene values +// inject into the flow's Flow Inputs; Flow Output values surface back +// (round-trip probed through a scene-side Math node reading the embed output) +// - the implicit-owner rule composes: a spin inside the flow reads the +// injected input as its speed +// - context-menu style embed (addObjectFlowToScene) + embed-once rule +// - deleting the flow removes its embedded node + edges +// - interface rename prunes stale embed edges (deterministic invariant) +const h = require('./helpers.cjs'); + +const sceneGraph = (peer) => + peer.page.evaluate(() => new Promise((r) => window.__stores.flowGraphs.subscribe((g) => r({ + nodes: g.scene.nodes.map((n) => ({ id: n.id, type: n.type })), + edges: g.scene.edges.map((e) => ({ id: e.id, source: e.source, target: e.target })) + }))())); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // --- scaffold: box + object flow with a declared interface ---------------- + const uuid = await A.page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create box'); + const g = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const box = g.children[g.children.length - 1]; + window.__stores.flowGraphsCtl.createObjectGraph(box.uuid); + const nh = window.__stores.nodesHandler; + // interface: input 'speed' -> spin.speed AND -> output 'echo' + nh.createFlowNode({ id: 'fi-speed', type: 'flowinput', position: { x: 0, y: 0 }, data: { type: 'flowinput', name: 'speed', vtype: 'number', fallback: 1 } }, box.uuid); + nh.createFlowNode({ id: 'spin-emb', type: 'spin', position: { x: 200, y: 0 }, data: { type: 'spin', axis: 'y', speed: 1 } }, box.uuid); + nh.createFlowNode({ id: 'fo-echo', type: 'flowoutput', position: { x: 200, y: 120 }, data: { type: 'flowoutput', name: 'echo' } }, box.uuid); + nh.createFlowEdge({ id: 'e-fi-spin', source: 'fi-speed', target: 'spin-emb', targetHandle: 'speed' }, box.uuid); + nh.createFlowEdge({ id: 'e-fi-echo', source: 'fi-speed', target: 'fo-echo', targetHandle: 'value' }, box.uuid); + return box.uuid; + }); + await A.page.waitForTimeout(400); + + // --- embed via the context-menu helper ------------------------------------ + const added = await A.page.evaluate((id) => window.__stores.objectFlow.addObjectFlowToScene(id, 'Box'), uuid); + h.check(added === true, 'addObjectFlowToScene embeds the flow into the scene graph'); + const addedTwice = await A.page.evaluate((id) => window.__stores.objectFlow.addObjectFlowToScene(id, 'Box'), uuid); + h.check(addedTwice === false, 'a flow embeds only once per graph (v1)'); + + // --- wire scene values through the embed ----------------------------------- + await A.page.evaluate(async (id) => { + const nh = window.__stores.nodesHandler; + let graphs; window.__stores.flowGraphs.subscribe((g) => (graphs = g))(); + const embed = graphs.scene.nodes.find((n) => n.type === 'objectflow' && n.data.flowUuid === id); + // scene: number 7 -> embed.speed ; embed.echo -> math(+0) probe + nh.createFlowNode({ id: 'num-7', type: 'number', position: { x: 0, y: 0 }, data: { type: 'number', value: 7 } }, 'scene'); + nh.createFlowNode({ id: 'probe', type: 'math', position: { x: 400, y: 0 }, data: { type: 'math', op: 'add', a: 0, b: 0 } }, 'scene'); + nh.createFlowEdge({ id: 'e-num-embed', source: 'num-7', target: embed.id, targetHandle: 'speed' }, 'scene'); + nh.createFlowEdge({ id: 'e-embed-probe', source: embed.id, sourceHandle: 'echo', target: 'probe', targetHandle: 'a' }, 'scene'); + }, uuid); + + // the probe math node should read 7 (scene -> flow input -> flow output -> scene) + await h.eventually( + () => + A.page.evaluate( + () => new Promise((r) => window.__stores.flowValues.subscribe((v) => r(v['probe']))()) + ), + (v) => v === 7, + 'scene value round-trips: injected input surfaces on the embed output (7)' + ); + // and the spin inside the flow drives the OWNER object (implicit rule intact) + await h.eventually( + () => A.page.evaluate((id) => window.__stores.flowRuntime.isAnimatedTarget(id), uuid), + (v) => v === true, + 'the flow’s spin animates the owner with the injected speed' + ); + + // --- interface change prunes stale embed edges ---------------------------- + await A.page.evaluate((id) => { + window.__stores.nodesHandler.updateFlowNodeData('fi-speed', { name: 'velocity' }, id); + }, uuid); + await A.page.waitForTimeout(500); + const afterRename = await sceneGraph(A); + h.check( + !afterRename.edges.some((e) => e.id === 'e-num-embed'), + 'renaming the Flow Input prunes the scene edge into the old socket' + ); + + // --- deleting the flow removes the embedded node --------------------------- + await A.page.evaluate((id) => window.__stores.flowGraphsCtl.deleteObjectGraph(id), uuid); + await A.page.waitForTimeout(400); + const afterDelete = await sceneGraph(A); + h.check(!afterDelete.nodes.some((n) => n.type === 'objectflow'), 'deleting the flow removes its embedded node'); + h.check(!afterDelete.edges.some((e) => e.id === 'e-embed-probe'), 'and its remaining edges'); + + await h.finish(browser); +}); From 056517676f058bf6764bdf1eff09488b7cff1376 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 22 Jul 2026 00:00:14 +0300 Subject: [PATCH 3/9] [feat] flow v2 H2+H3 - module node defs + Key Press trigger node - H2 api.registerNodeDefs: modules ship CODE-EDITABLE node definitions. Each def lands in the replicated customNodeDefs store (NodeDesigner-editable, palette Custom section, late-joiner handshake sync for free) with the id mod--; seeding is ABSENT-ONLY so a user-edited def is never clobbered by a module reload. hello module ships the worked example (Bobble node, mod-hello-bobble). - H3 Key Press node (Triggers group): fires a REPLICATED trigger pulse on a local keydown of its captured event.code (button-module pattern - local input never streams; peers compute the same pulse from the shared synced timestamp); held keys re-stamp before the pulse expires so the output stays 1 while held (bounded ~3/s). Press-a-key capture UI on the node; text fields are already filtered by inputRuntime. - TDZ cycle avoided: flowRuntime reaches inputRuntime via a PRIMED dynamic import - a static edge closes history -> flowRuntime -> inputRuntime -> shortcuts -> history (broke the SSR prerender with "Cannot access kindHandlers before initialization"); comment documents the loop. - e2e: flow-input-moddefs (6 checks, all pass - def seeded/editable/drives its H1 graph owner; keypress press/hold/release); build green; svelte-check 499/77 (under the 501/77 baseline). Co-Authored-By: Claude Fable 5 --- src/components/editors/Nodes.svelte | 2 + .../editors/nodes/KeyPressNode.svelte | 44 ++++++++++ src/lib/flowRuntime.js | 47 ++++++++++- src/lib/flowSockets.js | 4 +- src/lib/moduleSDK.js | 21 +++++ src/lib/nodeCatalog.js | 4 + src/lib/nodesHandler.js | 1 + src/modules/hello/module.js | 19 +++++ tests/e2e/flow-input-moddefs.test.cjs | 84 +++++++++++++++++++ 9 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 src/components/editors/nodes/KeyPressNode.svelte create mode 100644 tests/e2e/flow-input-moddefs.test.cjs diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index 31fc1bf5..9ce6fc92 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -43,6 +43,7 @@ import CounterNode from './nodes/CounterNode.svelte'; import FlowIONode from './nodes/FlowIONode.svelte'; import ObjectFlowNode from './nodes/ObjectFlowNode.svelte'; + import KeyPressNode from './nodes/KeyPressNode.svelte'; import { flowNodes as nodes, flowEdges as edges, customNodeDefs, nodeDesignerOpen, flowGraphs, activeGraphId, SCENE_GRAPH, setActiveGraph } from '../../stores/flowStore'; import { createObjectGraph, requestDeleteObjectGraph } from '$lib/flowGraphs'; import { objectsGroup, selectedObject } from '../../stores/sceneStore'; @@ -104,6 +105,7 @@ flowinput: FlowIONode, flowoutput: FlowIONode, objectflow: ObjectFlowNode, + keypress: KeyPressNode, ...moduleTypes }; diff --git a/src/components/editors/nodes/KeyPressNode.svelte b/src/components/editors/nodes/KeyPressNode.svelte new file mode 100644 index 00000000..50ecc448 --- /dev/null +++ b/src/components/editors/nodes/KeyPressNode.svelte @@ -0,0 +1,44 @@ + + + + + + + diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index f6554f2a..c98d1050 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -10,6 +10,13 @@ import { findNodeDef } from './customNodes'; import { updateSounds } from './soundRuntime'; import { startObjectFlowWatcher } from './objectFlow'; +// H3: inputRuntime is reached via a PRIMED dynamic import (the moduleSDK +// pattern) — a static edge would close the TDZ cycle history -> flowRuntime -> +// inputRuntime -> shortcuts -> history (inputRuntime pulls shortcuts for +// registerShortcut, and shortcuts' subtree reaches peerHandler -> flowGraphs, +// whose module body registers a history kind while history is mid-init). +/** @type {any} */ let inputRuntimeRef = null; + // Runs the node graph: applies colorpicker->objectselector colors on graph changes // and drives animation/effect nodes with a requestAnimationFrame loop. // Lives outside the Flow drawer so animations keep running when it is closed. @@ -172,7 +179,8 @@ export const valueTypes = [ 'number', 'vector3', 'toggle', 'random', 'time', 'math', 'compare', 'gate', 'loop', 'timer', 'distance', 'proximity', 'onclick', 'counter', // 134 'maprange', 'select', // 4.6 - 'flowinput', 'flowoutput', 'objectflow' // H5: object-flow composition + 'flowinput', 'flowoutput', 'objectflow', // H5: object-flow composition + 'keypress' // H3: keyboard trigger ]; // --- H5: object flows embedded in the scene graph ----------------------------- @@ -407,6 +415,13 @@ function evalNodeBody(node, allNodes, allEdges, time, seen, ctx) { const dt = trig ? time - trig.lastT : Infinity; return dt >= 0 && dt < num(d.pulse ?? 0.3) ? 1 : 0; } + case 'keypress': { + // H3: same pulse semantics as onclick — LOCAL keys arrive as replicated + // trigger stamps (held keys re-pulse, so this stays 1 while held) + const trig = ctx && ctx.triggers ? ctx.triggers[node.id] : null; + const dt = trig ? time - trig.lastT : Infinity; + return dt >= 0 && dt < num(d.pulse ?? 0.3) ? 1 : 0; + } case 'counter': return ctx && ctx.triggers && ctx.triggers[node.id] ? ctx.triggers[node.id].count : 0; // --- H5: object-flow composition --- @@ -733,6 +748,22 @@ function tick(now) { flowValues.set(values); } + // H3: while a Key Press node's key is HELD locally, re-stamp its trigger + // before the pulse expires so the output stays 1 (bounded re-broadcast, + // ~3/s per held node) + { + const held = inputRuntimeRef ? inputRuntimeRef.getInput().codes : new Set(); + if (held.size) { + const trigs = get(flowTriggers); + nodes.forEach((node) => { + if (node.type !== 'keypress' || !held.has(node.data?.code)) return; + const pulse = node.data?.pulse ?? 0.3; + const last = trigs[node.id]?.lastT ?? -Infinity; + if (time - last > pulse * 0.66) applyNodeTrigger(node.id, syncedNow(), true); + }); + } + } + // H5: harvest every object flow's declared outputs for the NEXT tick's // embedded Object Flow reads (one-frame latency by design) /** @type {Record>} */ @@ -785,6 +816,20 @@ export function startFlowRuntime() { // implicit-owner targeting. The mirror keeps the editor view in sync. startGraphMirror(); startObjectFlowWatcher(); // H5: embed-socket pruning on interface changes + // H3: LOCAL key presses pulse matching Key Press nodes — applyNodeTrigger + // REPLICATES the stamp (button-module pattern), so every peer computes the + // same pulse from the shared timestamp. Text fields are already filtered by + // inputRuntime; held keys re-pulse from the tick below. + import('./inputRuntime').then((m) => { + inputRuntimeRef = m; + m.onInput((/** @type {any} */ event) => { + if (event.type !== 'down') return; + nodes.forEach((node) => { + if (node.type === 'keypress' && node.data?.code === event.code) + applyNodeTrigger(node.id, syncedNow(), true); + }); + }); + }); flowGraphs.subscribe(() => { nodes = allNodes(); edges = allEdges(); diff --git a/src/lib/flowSockets.js b/src/lib/flowSockets.js index a1c8e487..d8569b4c 100644 --- a/src/lib/flowSockets.js +++ b/src/lib/flowSockets.js @@ -16,7 +16,9 @@ const OUTPUT = { toggle: 'boolean', compare: 'boolean', gate: 'boolean', proximity: 'boolean', colorpicker: 'color', objectselector: 'object', - onclick: 'event' + onclick: 'event', + keypress: 'event', // H3 + flowinput: 'number' // H5 fallback; the live check reads data.vtype }; /** typed named inputs; `_default` covers an unnamed target handle @type {Record>} */ diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 5b28065b..700348f4 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -113,6 +113,27 @@ function makeApi(moduleId) { registerEffect(type, fn) { moduleEffects[type] = fn; }, + /** + * H2 (flow v2): ship CODE-EDITABLE node definitions with the module. Each + * def becomes a regular custom node (NodeDesigner-editable, listed in the + * palette's Custom section, replicated like user defs) with the id + * `mod--`. Seeding is ABSENT-ONLY: a def the user edited + * (same id already in the store) is never clobbered on module reload. + * Def shape mirrors the NodeDesigner: {key, name, params: [{key, + * kind:'range'|'select', min?, max?, step?, options?}], code} — the code + * runs like a Script node (pure function of object/base/data/time; keep + * it deterministic, golden rule). + * @param {{key: string, name: string, params?: any[], code: string}[]} defs + */ + registerNodeDefs(defs) { + import('./customNodes').then((m) => { + for (const def of defs ?? []) { + const id = 'mod-' + moduleId + '-' + def.key; + if (m.findNodeDef(id)) continue; // user edits win over reseeds + m.applyNodeDef({ id, name: def.name ?? def.key, params: def.params ?? [], code: def.code ?? '' }); + } + }); + }, /** * Creatable geometry: `/create ...args` works locally and on * peers. `entry` ({label, command, group?}) lists it in the sidebar. diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index 795c458d..33bd3602 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -78,6 +78,10 @@ export const nodeCatalog = [ items: [ // 134: EVENT nodes — ride small replicated trigger messages, not state { type: 'onclick', label: 'On Click', defaults: { pulse: 0.3 } }, + // H3: keyboard trigger — LOCAL key presses replicate as trigger pulses + // (golden rule: never stream local state); held keys re-pulse so the + // output stays high while held + { type: 'keypress', label: 'Key Press', defaults: { code: 'KeyR', pulse: 0.3 } }, { type: 'counter', label: 'Counter', defaults: { op: 'up', step: 1 } } ] }, diff --git a/src/lib/nodesHandler.js b/src/lib/nodesHandler.js index 09afc402..21a1bdcd 100644 --- a/src/lib/nodesHandler.js +++ b/src/lib/nodesHandler.js @@ -220,6 +220,7 @@ export function applyNodeSync(data) { conn.send({ type: 'getnodes', sender: peer.peer.id }); } +/** @type {any} */ let syncTimer = null; /** Broadcast our graph hash every 10s so peers can detect drift */ diff --git a/src/modules/hello/module.js b/src/modules/hello/module.js index 77e0c55b..5ae42ef3 100644 --- a/src/modules/hello/module.js +++ b/src/modules/hello/module.js @@ -29,5 +29,24 @@ export default { const speed = data.speed ?? 2; object.rotation.z = base.rot[2] + Math.sin(time * speed) * amplitude; }); + + // H2 (flow v2): the worked example for registerNodeDefs — a CODE-EDITABLE + // node (open it in the Node Designer to tweak the formula). Ships as a + // regular custom node with the id mod-hello-bobble; user edits persist. + api.registerNodeDefs([ + { + key: 'bobble', + name: 'Bobble (hello)', + params: [ + { key: 'height', kind: 'range', min: 0, max: 2, step: 0.05 }, + { key: 'speed', kind: 'range', min: 0.2, max: 10, step: 0.1 } + ], + code: + '// editable module node (H2): bobs the object up and down\n' + + 'const height = data.height ?? 0.5;\n' + + 'const speed = data.speed ?? 3;\n' + + 'object.position.y = base.pos[1] + Math.abs(Math.sin(time * speed)) * height;\n' + } + ]); } }; diff --git a/tests/e2e/flow-input-moddefs.test.cjs b/tests/e2e/flow-input-moddefs.test.cjs new file mode 100644 index 00000000..a41e850b --- /dev/null +++ b/tests/e2e/flow-input-moddefs.test.cjs @@ -0,0 +1,84 @@ +// Roadmap #13 H2 + H3. +// H2 api.registerNodeDefs: the hello module seeds a code-editable custom node +// (mod-hello-bobble) that drives objects like any custom node; reseeding +// never clobbers a user-edited def. +// H3 Key Press node: a real keydown pulses the node (replicated trigger) and +// its pulse drives a wired consumer; holding keeps the output high. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // --- H2: module def seeded ------------------------------------------------- + const def = await A.page.evaluate( + () => new Promise((r) => window.__stores.customNodeDefs.subscribe((d) => r(d.find((x) => x.id === 'mod-hello-bobble') ?? null))()) + ); + h.check(!!def && /position\.y/.test(def.code), 'H2: hello module seeded the editable Bobble node def'); + + // the def is editable like any custom node (applyNodeDef updates in place; + // the registerNodeDefs absent-only guard means reseeds never clobber this) + await A.page.evaluate(() => { + let defs; window.__stores.customNodeDefs.subscribe((d) => (defs = d))(); + const mine = { ...defs.find((x) => x.id === 'mod-hello-bobble'), code: '// user edited\nobject.position.y = base.pos[1] + Math.sin(time * (data.speed ?? 3)) * (data.height ?? 0.5);\n' }; + window.__stores.customNodes.applyNodeDef(mine); + }); + const after = await A.page.evaluate( + () => new Promise((r) => window.__stores.customNodeDefs.subscribe((d) => r(d.find((x) => x.id === 'mod-hello-bobble').code))()) + ); + h.check(after.includes('user edited'), 'H2: the module def is user-editable in place'); + + // the def drives an object end-to-end (customnode instance in an OBJECT graph, + // implicit owner — composes H1+H2) + const uuid = await A.page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create box'); + const g = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const box = g.children[g.children.length - 1]; + window.__stores.flowGraphsCtl.createObjectGraph(box.uuid); + window.__stores.nodesHandler.createFlowNode( + { id: 'bob-1', type: 'customnode', position: { x: 0, y: 0 }, data: { type: 'customnode', defId: 'mod-hello-bobble', height: 1, speed: 3 } }, + box.uuid + ); + return box.uuid; + }); + await h.eventually( + () => A.page.evaluate((id) => window.__stores.flowRuntime.isAnimatedTarget(id), uuid), + (v) => v === true, + 'H2+H1: the module def animates its graph owner implicitly' + ); + + // --- H3: Key Press node ----------------------------------------------------- + await A.page.evaluate(() => { + const nh = window.__stores.nodesHandler; + nh.createFlowNode({ id: 'key-r', type: 'keypress', position: { x: 0, y: 0 }, data: { type: 'keypress', code: 'KeyR', pulse: 0.4 } }, 'scene'); + }); + await A.page.waitForTimeout(300); + // focus the canvas area (not a text field) and press R + await A.page.mouse.click(400, 400); + await A.page.keyboard.down('r'); + await h.eventually( + () => + A.page.evaluate( + () => new Promise((r) => window.__stores.flowValues.subscribe((v) => r(v['key-r']))()) + ), + (v) => v === 1, + 'H3: pressing R pulses the Key Press node (output 1)' + ); + // held: stays 1 well past a single pulse window + await A.page.waitForTimeout(900); + const stillHeld = await A.page.evaluate( + () => new Promise((r) => window.__stores.flowValues.subscribe((v) => r(v['key-r']))()) + ); + h.check(stillHeld === 1, 'H3: holding the key keeps the output high (re-pulse)'); + await A.page.keyboard.up('r'); + await h.eventually( + () => + A.page.evaluate( + () => new Promise((r) => window.__stores.flowValues.subscribe((v) => r(v['key-r']))()) + ), + (v) => v === 0, + 'H3: releasing the key drops the output after the pulse expires' + ); + + await h.finish(browser); +}); From 0788ce6a3ee49ba3b7602e5e54defe66007b53da Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 22 Jul 2026 00:39:37 +0300 Subject: [PATCH 4/9] [fix] flow v2 - empty-click deselect returns the editor to the Scene flow - scope-follows-selection now reads the selectedObjects SET (deselectObject clears only the set; selectedObject keeps the last object on purpose for the inspector/outline - the editor stayed stuck on the object flow) - new Scene chip button in the flow editor: switches back to the Scene flow AND deselects the current object (deselectObject) - e2e: flow-object-graphs extended to 20 checks (real deselect path + the chip button, all pass); build green; svelte-check 499/77 held Co-Authored-By: Claude Fable 5 --- src/components/editors/Nodes.svelte | 24 ++++++++++++++++++++---- tests/e2e/flow-object-graphs.test.cjs | 18 ++++++++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index 9ce6fc92..675c361e 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -46,7 +46,8 @@ import KeyPressNode from './nodes/KeyPressNode.svelte'; import { flowNodes as nodes, flowEdges as edges, customNodeDefs, nodeDesignerOpen, flowGraphs, activeGraphId, SCENE_GRAPH, setActiveGraph } from '../../stores/flowStore'; import { createObjectGraph, requestDeleteObjectGraph } from '$lib/flowGraphs'; - import { objectsGroup, selectedObject } from '../../stores/sceneStore'; + import { deselectObject } from '$lib/objectActions'; + import { objectsGroup, selectedObject, selectedObjects } from '../../stores/sceneStore'; import { serializeNode, serializeEdge, deleteFlowNodes, deleteFlowEdges, setNodeData } from '$lib/nodesHandler'; import ThemedSelect from '../ui/ThemedSelect.svelte'; import { defDefaults } from '$lib/customNodes'; @@ -137,10 +138,14 @@ // H1 (flow v2): the editor scope follows the viewport selection — a selected // object shows ITS graph (or the create-flow empty state), deselecting returns - // to the scene graph. setActiveGraph no-ops on repeats. + // to the scene graph. "Has a selection" MUST be read from the selectedObjects + // SET: selectedObject keeps the last object after a deselect on purpose (the + // inspector/outline bind to it), so an empty-space click clears only the set. $: { - const uuid = ($selectedObject as any)?.uuid; - setActiveGraph(uuid ?? SCENE_GRAPH); + const set = $selectedObjects as string[]; + const primary = ($selectedObject as any)?.uuid; + const scopeUuid = set.length ? (primary && set.includes(primary) ? primary : set[set.length - 1]) : null; + setActiveGraph(scopeUuid ?? SCENE_GRAPH); } $: activeId = $activeGraphId; $: hasActiveGraph = activeId === SCENE_GRAPH || !!$flowGraphs[activeId]; @@ -550,6 +555,17 @@ id="flow-scope-chip" class="pointer-events-none absolute left-1/2 top-2 z-10 flex -translate-x-1/2 items-center gap-1.5" > + {#if activeId !== SCENE_GRAPH} + + + {/if} diff --git a/tests/e2e/flow-object-graphs.test.cjs b/tests/e2e/flow-object-graphs.test.cjs index 863bfb90..ce7361c3 100644 --- a/tests/e2e/flow-object-graphs.test.cjs +++ b/tests/e2e/flow-object-graphs.test.cjs @@ -64,9 +64,23 @@ h.run(async () => { ); // --- deselect returns to the scene graph ---------------------------------- - await A.page.evaluate(() => window.__stores.selectedObject.set([])); + // the REAL empty-click path: deselectObject clears the selectedObjects SET but + // keeps selectedObject (inspector/outline gotcha) — scope must still return + await A.page.evaluate(() => window.__stores.objectActions.deselectObject()); await A.page.waitForTimeout(400); - h.check((await activeOf(A)) === 'scene', 'deselecting returns the editor to the scene graph'); + h.check((await activeOf(A)) === 'scene', 'empty-click deselect returns the editor to the scene graph'); + + // --- explicit "Scene" chip button switches scope AND deselects ------------- + await A.page.evaluate((id) => window.__stores.objectActions.selectObject(id), uuid); + await A.page.waitForTimeout(400); + h.check((await activeOf(A)) === uuid, 'reselecting scopes back to the object'); + await A.page.locator('#flow-scope-scene').click(); + await A.page.waitForTimeout(400); + h.check((await activeOf(A)) === 'scene', 'the Scene chip button returns to the scene flow'); + const setAfter = await A.page.evaluate( + () => new Promise((r) => window.__stores.selectedObjects.subscribe((s) => r(s.length))()) + ); + h.check(setAfter === 0, 'the Scene chip button also deselects the object'); // --- two-peer: the object graph replicates -------------------------------- const B = await h.setupPage(browser, 'B'); From c2b819bf193a5310afe1b01d3854cf64eee0fd95 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 22 Jul 2026 00:47:39 +0300 Subject: [PATCH 5/9] [fix] autosave restore keeps object uuids - object flows survive the snapshot - root cause (user-reported): the autosave scene round-trips through GLTF and GLTFLoader assigns NEW uuids on parse - the restored graph stayed keyed by the ORIGINAL uuid, so the object came back flowless (annotations share the same uuid keying and orphaned too). Verified with a failing e2e first. - fix: exportScene stamps every object's uuid into userData.__uuid (GLTF extras round-trip it; markers stripped after export), and restoreSnapshot re-assigns the original uuids before adding children / re-broadcasting - peers receive the same uuids the graphs are keyed by. - e2e: autosave-object-flows (6 checks: save -> reload -> restore offer -> restore -> box keeps its uuid, its flow has its node, no empty state); flow-object-graphs regression all pass; build green; svelte-check 499/77. Co-Authored-By: Claude Fable 5 --- src/lib/autosave.js | 23 +++++++++ tests/e2e/autosave-object-flows.test.cjs | 61 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tests/e2e/autosave-object-flows.test.cjs diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 12ac303c..d8a6ee41 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -34,13 +34,26 @@ function exportScene() { if (!group || group.children.length === 0) return resolve(null); // snapshots must store animation BASE poses, not the current swing (88) const restore = parkAnimatedAtBase(); + // H1 fix: GLTFLoader assigns NEW uuids on parse, which orphans everything + // keyed by object uuid (object flows, annotations). Stamp each object's + // uuid into userData (GLTF extras round-trips it) so restoreSnapshot can + // re-assign the ORIGINAL uuids; markers are stripped again after export. + group.traverse((/** @type {any} */ child) => { + if (child !== group) child.userData.__uuid = child.uuid; + }); + const unstamp = () => + group.traverse((/** @type {any} */ child) => { + if (child.userData && '__uuid' in child.userData) delete child.userData.__uuid; + }); new GLTFExporter().parse( group, (result) => { + unstamp(); restore(); resolve(result); }, (error) => { + unstamp(); restore(); console.log('autosave export failed', error); resolve(null); @@ -150,6 +163,16 @@ export async function restoreSnapshot() { result.scene.getObjectByName('AuxScene')?.children?.[0] ?? result.scene.children[0] ?? result.scene; + // H1 fix: restore the ORIGINAL uuids stamped at export time — object + // flows/annotations are keyed by them, and the re-broadcast below then + // carries the same uuids to peers + container.traverse((/** @type {any} */ child) => { + const saved = child.userData?.__uuid; + if (saved) { + child.uuid = saved; + delete child.userData.__uuid; + } + }); /** @type {any} */ const peer = get(peers); [...container.children].forEach((child) => { diff --git a/tests/e2e/autosave-object-flows.test.cjs b/tests/e2e/autosave-object-flows.test.cjs new file mode 100644 index 00000000..8674dbac --- /dev/null +++ b/tests/e2e/autosave-object-flows.test.cjs @@ -0,0 +1,61 @@ +// Roadmap #13 H1 follow-up — object flows must survive the autosave restore. +// The autosave scene round-trips through GLTF (exporter -> loader), and +// GLTFLoader assigns NEW uuids on parse; graphs are keyed by object uuid, so a +// restored object must come back with its ORIGINAL uuid or its flow orphans +// (the user-reported bug). Annotations share the same uuid-keying. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // --- author: box + object flow with one node, then snapshot ---------------- + const uuid = await A.page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create box'); + const g = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const box = g.children[g.children.length - 1]; + box.name = 'FlowBox'; + window.__stores.flowGraphsCtl.createObjectGraph(box.uuid); + window.__stores.nodesHandler.createFlowNode( + { id: 'spin-saved', type: 'spin', position: { x: 0, y: 0 }, data: { type: 'spin', axis: 'y', speed: 2 } }, + box.uuid + ); + await window.__stores.autosave.saveNow(); + return box.uuid; + }); + h.check(!!uuid, 'authored a box with an object flow and saved a snapshot'); + + // --- reload + restore ------------------------------------------------------- + await h.freshReload(A); + await h.eventually( + () => A.page.evaluate(() => new Promise((r) => window.__stores.autosave.restoreAvailable.subscribe((v) => r(!!v))())), + (v) => v === true, + 'the restore offer appears after reload' + ); + await A.page.evaluate(() => window.__stores.autosave.restoreSnapshot()); + await A.page.waitForTimeout(1500); + + const state = await A.page.evaluate(async () => { + const g = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const box = g.children.find((c) => c.name === 'FlowBox'); + let graphs; window.__stores.flowGraphs.subscribe((v) => (graphs = v))(); + return { + boxUuid: box?.uuid ?? null, + graphKeys: Object.keys(graphs).filter((k) => k !== 'scene'), + nodesUnderBox: box ? (graphs[box.uuid]?.nodes?.length ?? -1) : -1 + }; + }); + h.check(!!state.boxUuid, 'the box came back from the snapshot'); + h.check(state.boxUuid === uuid, 'the restored box keeps its ORIGINAL uuid (GLTF round-trip)'); + h.check(state.nodesUnderBox === 1, 'the restored box still owns its flow (1 node)'); + + // selecting it scopes the editor to a real graph, not the empty state + await A.page.evaluate(async (id) => { + window.__stores.flowGraphClose.set(false); + window.__stores.objectActions.selectObject(id); + }, state.boxUuid); + await A.page.waitForTimeout(500); + h.check((await A.page.locator('#flow-empty-state').count()) === 0, 'no empty state — the flow is attached'); + + await h.finish(browser); +}); From f861f8a6fcab20b6590b43aef4229c41e1b7fc69 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 22 Jul 2026 00:59:10 +0300 Subject: [PATCH 6/9] [feat] flow v2 - Object Flow node: labeled stretching sockets + dblclick opens the flow - the embed card now renders ONE labeled row per declared socket (inputs left, outputs right, names truncated) with each handle anchored to its row ON the card edge - previous absolute offsets anchored to the padded content box and drifted from the labels; the card stretches with the interface - double-clicking the card selects the owner object, which opens its flow in the editor (the H1 scope-follows-selection rule); hint line on the card - e2e: flow-object-embed extended to 8 checks (real dblclick, all pass); build green; svelte-check 499/77 held; layout verified by screenshot Co-Authored-By: Claude Fable 5 --- .../editors/nodes/ObjectFlowNode.svelte | 89 +++++++++++-------- tests/e2e/flow-object-embed.test.cjs | 19 ++++ 2 files changed, 70 insertions(+), 38 deletions(-) diff --git a/src/components/editors/nodes/ObjectFlowNode.svelte b/src/components/editors/nodes/ObjectFlowNode.svelte index f41ee0be..dffceed4 100644 --- a/src/components/editors/nodes/ObjectFlowNode.svelte +++ b/src/components/editors/nodes/ObjectFlowNode.svelte @@ -5,12 +5,14 @@ import { setNodeData } from '$lib/nodesHandler'; import { flowGraphs, SCENE_GRAPH } from '../../../stores/flowStore'; import { objectsGroup } from '../../../stores/sceneStore'; + import { selectObject } from '$lib/objectActions'; // H5: an object flow EMBEDDED in the scene graph. Sockets come from the flow's - // declared Flow Input / Flow Output interface nodes — inputs on the left feed - // the flow's Flow Inputs, its Flow Output values surface on the right (one - // frame of latency). Pick a target object below; only objects that HAVE a - // flow are listed. + // declared Flow Input / Flow Output interface nodes — one labeled ROW per + // socket (the card stretches with the interface), with the handle anchored to + // its row. DOUBLE-CLICK the card to open the object's flow (selects the object; + // the editor scope follows the selection). Only objects that HAVE a flow are + // listed in the picker. type $$Props = NodeProps; export let id: string; export let data; @@ -43,40 +45,51 @@ return { inputs, outputs }; })(); - // stacked socket offsets: header ~30px + picker ~40px, then 22px per row - const ROW0 = 74; - const ROW = 22; + function openFlow() { + // selecting the owner flips the editor scope to its flow (H1 rule) + if (data.flowUuid) selectObject(data.flowUuid); + } - - + + {#each iface.inputs as socket (socket.name)} +
+ + {socket.name} +
{/each} - - - {#each iface.inputs as socket, i (socket.name)} - - {/each} - {#each iface.outputs as socket, i (socket.name)} - - {/each} -
- {#each iface.inputs as socket (socket.name)} -
â–¸ {socket.name}
- {/each} - {#each iface.outputs as socket (socket.name)} -
{socket.name} â–¸
- {/each} - {#if data.flowUuid && !iface.inputs.length && !iface.outputs.length} -
no Flow Input/Output declared
- {/if} -
-
+ {#each iface.outputs as socket (socket.name)} +
+ {socket.name} + +
+ {/each} + {#if data.flowUuid && !iface.inputs.length && !iface.outputs.length} +
no Flow Input/Output declared
+ {/if} + {#if data.flowUuid} +
double-click to open
+ {/if} +
+ +
diff --git a/tests/e2e/flow-object-embed.test.cjs b/tests/e2e/flow-object-embed.test.cjs index 98bf1ac3..eeaace5d 100644 --- a/tests/e2e/flow-object-embed.test.cjs +++ b/tests/e2e/flow-object-embed.test.cjs @@ -71,6 +71,25 @@ h.run(async () => { 'the flow’s spin animates the owner with the injected speed' ); + // --- double-clicking the embed node opens the object's flow ---------------- + await A.page.evaluate(async (id) => { + window.__stores.flowGraphClose.set(false); + // park the embed node in an unobstructed spot for the real dblclick + let graphs; window.__stores.flowGraphs.subscribe((g) => (graphs = g))(); + const embed = graphs.scene.nodes.find((n) => n.type === 'objectflow' && n.data.flowUuid === id); + window.__stores.nodesHandler.moveFlowNode(embed.id, { x: 620, y: 260 }, 'scene'); + }, uuid); + await A.page.waitForTimeout(600); + await A.page.locator('.svelte-flow__node').filter({ hasText: 'double-click to open' }).first().dblclick(); + await A.page.waitForTimeout(500); + const scopeAfterDbl = await A.page.evaluate( + () => new Promise((r) => window.__stores.activeGraphId.subscribe((v) => r(v))()) + ); + h.check(scopeAfterDbl === uuid, 'double-clicking the embed node opens the object flow'); + // back to the scene graph for the remaining checks + await A.page.evaluate(() => window.__stores.objectActions.deselectObject()); + await A.page.waitForTimeout(400); + // --- interface change prunes stale embed edges ---------------------------- await A.page.evaluate((id) => { window.__stores.nodesHandler.updateFlowNodeData('fi-speed', { name: 'velocity' }, id); From 8e508c2b25465a96c66b012ebab97df73e043599 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 22 Jul 2026 01:00:43 +0300 Subject: [PATCH 7/9] [fix] type the ObjectFlowNode data prop - svelte-check back to 499/77 --- src/components/editors/nodes/ObjectFlowNode.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/editors/nodes/ObjectFlowNode.svelte b/src/components/editors/nodes/ObjectFlowNode.svelte index dffceed4..4225885d 100644 --- a/src/components/editors/nodes/ObjectFlowNode.svelte +++ b/src/components/editors/nodes/ObjectFlowNode.svelte @@ -15,7 +15,7 @@ // listed in the picker. type $$Props = NodeProps; export let id: string; - export let data; + export let data: any; $: candidates = Object.keys($flowGraphs) .filter((g) => g !== SCENE_GRAPH) From 20bf62c187e099a041e030d760d74f19e1e52861 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 22 Jul 2026 01:16:07 +0300 Subject: [PATCH 8/9] [feat] flow v2 - IO node UX: column layout, typed fallbacks, any-socket, wired readouts - FlowIONode + KeyPressNode fields now stack in ONE column (NodeWrapper's slot is a flex ROW - three sibling labels squeezed side-by-side and the name field showed three letters) - Flow Input: changing the declared type RESETS the fallback to a typed default and the fallback editor matches the type (number input / checkbox / color swatch / x-y-z fields) - Flow Output input socket (and Object Flow output sockets) are the neutral gray 'any' type - they accept any VALUE; only effect wires are blocked (effects are animations, not values - the docs example now says to output the driving Time value, not the Pulse) - wired params show the LIVE incoming value instead of their slider (Animation + Custom node cards): a flowValues lookup - the runtime already publishes value-node outputs ~6/s for card readouts, so this renders for free with no new evaluation - e2e: flow-object-embed 10 checks + flow-input-moddefs 7 checks (typed fallback reset, wired readout replaces the slider) all pass; build green; svelte-check 499/77 held; layouts verified by screenshots Co-Authored-By: Claude Fable 5 --- .../editors/nodes/AnimationNode.svelte | 25 +++- .../editors/nodes/CustomNode.svelte | 23 +++- .../editors/nodes/FlowIONode.svelte | 117 +++++++++++++----- .../editors/nodes/KeyPressNode.svelte | 41 +++--- .../editors/nodes/ObjectFlowNode.svelte | 3 +- tests/e2e/flow-input-moddefs.test.cjs | 27 ++++ tests/e2e/flow-object-embed.test.cjs | 17 +++ 7 files changed, 196 insertions(+), 57 deletions(-) diff --git a/src/components/editors/nodes/AnimationNode.svelte b/src/components/editors/nodes/AnimationNode.svelte index 9f489283..33536956 100644 --- a/src/components/editors/nodes/AnimationNode.svelte +++ b/src/components/editors/nodes/AnimationNode.svelte @@ -4,14 +4,28 @@ import NodeWrapper from './NodeWrapper.svelte'; import { setNodeData } from '$lib/nodesHandler'; import { findNodeSpec } from '$lib/nodeCatalog'; + import { flowEdges, flowValues } from '../../../stores/flowStore'; type $$Props = NodeProps; export let id: string; - export let data; + export let data: any; // Controls are described by the catalog spec for this node type $: spec = findNodeSpec(data.type); // One-way flow: render from data, write through setNodeData (replicates to peers) + + // A WIRED param shows the incoming live value instead of its slider — the + // manual value is overridden anyway (resolveInputs). Free to render: the + // runtime already publishes every value node's output into flowValues ~6/s + // for the card readouts, so this is a lookup, not a new evaluation. + $: wiredSource = (key: string) => + ($flowEdges as any[]).find((e) => e.target === id && e.targetHandle === key)?.source ?? null; + function fmt(v: any) { + if (v === undefined || v === null) return '…'; + if (typeof v === 'number') return (+v).toFixed(2); + if (Array.isArray(v)) return v.map((n) => (+n).toFixed(1)).join(', '); + return String(v); + } @@ -28,11 +42,16 @@