Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,11 @@
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'),
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 }
})
}
})
Expand Down
13 changes: 6 additions & 7 deletions src/components/editors/Explorer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
126 changes: 111 additions & 15 deletions src/components/editors/Nodes.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,18 @@
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 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 { 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';
import { findNodeSpec, nodeCatalog } from '$lib/nodeCatalog';
import { isValidFlowConnection, typeColor } from '$lib/flowSockets';
import { isValidFlowConnection, typeColor, replaceableInputEdges } from '$lib/flowSockets';
import { moduleNodeGroups, moduleNodeComponents } from '$lib/moduleSDK';
import { rightDragMove, inputContextMenu } from '$lib/searchMenuUx';
import { peers, username } from '../../stores/appStore';
Expand Down Expand Up @@ -97,6 +103,10 @@
visibility: EffectNode,
onclick: OnClickNode,
counter: CounterNode,
flowinput: FlowIONode,
flowoutput: FlowIONode,
objectflow: ObjectFlowNode,
keypress: KeyPressNode,
...moduleTypes
};

Expand Down Expand Up @@ -126,6 +136,26 @@
$: 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. "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 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];
$: 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);
Expand Down Expand Up @@ -153,7 +183,8 @@
id: peer.peer.id,
name: $username || peer.peer.id,
x: position.x,
y: position.y
y: position.y,
graphId: activeId
});
};
const onPointerLeaveCursor = () => {
Expand All @@ -164,6 +195,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(),
Expand All @@ -180,7 +215,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
Expand Down Expand Up @@ -238,7 +273,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 });
});
};

Expand All @@ -251,6 +286,14 @@
// BOTH a and b of a node collided ids, the peer-side dedupe dropped edge #2
// and the graphs diverged permanently (nodesync could never converge).
const onedgecreate = (connection: Connection) => {
// single-connection inputs: a new wire into an occupied VALUE input
// replaces the old one (effect/event inputs keep multi fan-in; fan-out
// from an output is always unlimited)
const stale = replaceableInputEdges(connection, get(nodes), get(edges));
if (stale.length) {
deleteFlowEdges(stale, activeId);
peer?.send({ type: 'edgedelete', ids: stale, graphId: activeId });
}
const edge = {
id: `e-${connection.source}${connection.sourceHandle ? '.' + connection.sourceHandle : ''}-${connection.target}${connection.targetHandle ? '.' + connection.targetHandle : ''}`,
source: connection.source,
Expand All @@ -262,36 +305,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 }>) => {
Expand Down Expand Up @@ -337,7 +380,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 }>) => {
Expand Down Expand Up @@ -514,6 +557,59 @@
on:pointermove={onPointerMoveCursor}
on:pointerleave={onPointerLeaveCursor}
>
<!-- H1: graph-scope chip — which flow the editor shows (follows the viewport
selection). Object flows get a delete action (confirmation toast). -->
<div
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}
<!-- explicit way back: show the Scene flow AND deselect the object -->
<button
id="flow-scope-scene"
class="pointer-events-auto rounded-full border border-gray-700/60 bg-gray-800/85 px-2 py-0.5 text-xs text-gray-400 backdrop-blur hover:text-gray-100"
title="Back to the Scene flow (deselects the object)"
on:click={() => deselectObject()}
>
⌂ Scene
</button>
{/if}
<span
class="pointer-events-auto rounded-full border border-gray-700/60 bg-gray-800/85 px-2.5 py-0.5 text-xs text-gray-200 backdrop-blur"
>
{activeId === SCENE_GRAPH ? 'Scene flow' : activeOwnerName + ' — object flow'}
</span>
{#if activeId !== SCENE_GRAPH && hasActiveGraph}
<button
id="flow-scope-delete"
class="pointer-events-auto rounded-full border border-gray-700/60 bg-gray-800/85 px-2 py-0.5 text-xs text-gray-400 backdrop-blur hover:text-red-400"
title="Delete this object's flow"
on:click={() => requestDeleteObjectGraph(activeId, activeOwnerName)}
>
🗑
</button>
{/if}
</div>

<!-- H1: empty state — the selected object has no flow document yet -->
{#if activeId !== SCENE_GRAPH && !hasActiveGraph}
<div
id="flow-empty-state"
class="absolute inset-0 z-[5] flex flex-col items-center justify-center gap-3 bg-gray-900/60 backdrop-blur-[2px]"
>
<p class="text-sm text-gray-300">
<span class="font-semibold text-gray-100">{activeOwnerName}</span> has no flow yet
</p>
<button
id="flow-create-btn"
class="rounded-lg bg-primary-700 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600"
on:click={() => createObjectGraph(activeId)}
>
Create flow
</button>
<p class="text-[11px] text-gray-500">Nodes here will drive this object (no Object Selector needed)</p>
</div>
{/if}
<SvelteFlow
{nodes}
{nodeTypes}
Expand Down
5 changes: 3 additions & 2 deletions src/components/editors/PeerCursors.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
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.
Expand Down Expand Up @@ -31,7 +31,8 @@
</script>

<div class="pointer-events-none absolute inset-0 overflow-hidden" style="z-index: 10;">
{#each Object.entries($flowCursors) as [id, cursor] (id)}
<!-- H1: only cursors on the SAME graph as this editor (missing graphId = scene) -->
{#each Object.entries($flowCursors).filter(([, c]) => ((c as any).graphId ?? SCENE_GRAPH) === $activeGraphId) as [id, cursor] (id)}
<div
class="absolute flex items-start"
style="left: {cursor.x * $viewport.zoom + $viewport.x}px; top: {cursor.y * $viewport.zoom + $viewport.y}px;"
Expand Down
25 changes: 22 additions & 3 deletions src/components/editors/nodes/AnimationNode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
</script>

<NodeWrapper type={data.type} label={data.label}>
Expand All @@ -28,11 +42,16 @@
<label class="flex flex-col">
<span class="flex justify-between">
<span>{param.key}</span>
{#if param.kind === 'range'}
{#if param.kind === 'range' && !wiredSource(param.key)}
<span>{data[param.key] ?? spec.defaults[param.key]}</span>
{/if}
</span>
{#if param.kind === 'range'}
{#if param.kind === 'range' && wiredSource(param.key)}
<!-- wired: the incoming value drives this param — show it live -->
<span class="wired-value rounded bg-gray-900/70 px-1.5 py-0.5 font-mono text-[11px] text-primary-300" title="Driven by the wired input">
◈ {fmt($flowValues[wiredSource(param.key)])}
</span>
{:else if param.kind === 'range'}
<input
class="nodrag accent-[#ff4000]"
type="range"
Expand Down
Loading