diff --git a/Cargo.lock b/Cargo.lock index a02c5d1a..5139d736 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1576,6 +1576,7 @@ dependencies = [ name = "graphite-wasm" version = "0.0.0" dependencies = [ + "bezier-rs", "graph-craft", "graphite-document-legacy", "graphite-editor", diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs index 3b700173..955a9d4c 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs @@ -61,4 +61,7 @@ pub enum NodeGraphMessage { input_index: usize, value: TaggedValue, }, + ShiftNode { + node_id: NodeId, + }, } diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index 3a047665..175580ad 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -530,6 +530,53 @@ impl MessageHandler { + let Some(network) = self.get_active_network_mut(document) else{ + warn!("No network"); + return; + }; + let outwards_links = network.collect_outwards_links(); + let required_shift = |left: NodeId, right: NodeId, network: &NodeNetwork| { + if let (Some(left), Some(right)) = (network.nodes.get(&left), network.nodes.get(&right)) { + if right.metadata.position.0 < left.metadata.position.0 { + 0 + } else { + (8 - (right.metadata.position.0 - left.metadata.position.0)).max(0) + } + } else { + 0 + } + }; + let shift_node = |node_id: NodeId, shift: i32, network: &mut NodeNetwork| { + if let Some(node) = network.nodes.get_mut(&node_id) { + node.metadata.position.0 += shift + } + }; + // Shift the actual node + let inputs = network + .nodes + .get(&node_id) + .map_or(&Vec::new(), |node| &node.inputs) + .iter() + .filter_map(|input| if let NodeInput::Node(previous_id) = input { Some(*previous_id) } else { None }) + .collect::>(); + + for input_node in inputs { + let shift = required_shift(input_node, node_id, network); + shift_node(node_id, shift, network); + } + + // Shift nodes connected to the output port of the specified node + for &decendant in outwards_links.get(&node_id).unwrap_or(&Vec::new()) { + let shift = required_shift(node_id, decendant, network); + let mut stack = vec![decendant]; + while let Some(id) = stack.pop() { + shift_node(id, shift, network); + stack.extend(outwards_links.get(&id).unwrap_or(&Vec::new()).iter().copied()) + } + } + Self::send_graph(network, responses); + } } } diff --git a/frontend/src/components/panels/NodeGraph.vue b/frontend/src/components/panels/NodeGraph.vue index 1f85cbc6..7cdc5c00 100644 --- a/frontend/src/components/panels/NodeGraph.vue +++ b/frontend/src/components/panels/NodeGraph.vue @@ -300,6 +300,8 @@ import { defineComponent, nextTick } from "vue"; import type { IconName } from "@/utility-functions/icons"; +import type { FrontendNodeLink } from "@/wasm-communication/messages"; + import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue"; import TextButton from "@/components/widgets/buttons/TextButton.vue"; @@ -388,6 +390,15 @@ export default defineComponent({ }, }, methods: { + resolveLink(link: FrontendNodeLink, containerBounds: HTMLDivElement): { nodePrimaryOutput: HTMLDivElement | undefined; nodePrimaryInput: HTMLDivElement | undefined } { + const connectorIndex = Number(link.linkEndInputIndex); + + const nodePrimaryOutput = (containerBounds.querySelector(`[data-node="${String(link.linkStart)}"] [data-port="output"]`) || undefined) as HTMLDivElement | undefined; + + const nodeInputConnectors = containerBounds.querySelectorAll(`[data-node="${String(link.linkEnd)}"] [data-port="input"]`) || undefined; + const nodePrimaryInput = nodeInputConnectors?.[connectorIndex] as HTMLDivElement | undefined; + return { nodePrimaryOutput, nodePrimaryInput }; + }, async refreshLinks(): Promise { await nextTick(); @@ -396,13 +407,7 @@ export default defineComponent({ const links = this.nodeGraph.state.links; this.nodeLinkPaths = links.flatMap((link, index) => { - const connectorIndex = Number(link.linkEndInputIndex); - - const nodePrimaryOutput = (containerBounds.querySelector(`[data-node="${String(link.linkStart)}"] [data-port="output"]`) || undefined) as HTMLDivElement | undefined; - - const nodeInputConnectors = containerBounds.querySelectorAll(`[data-node="${String(link.linkEnd)}"] [data-port="input"]`) || undefined; - const nodePrimaryInput = nodeInputConnectors?.[connectorIndex] as HTMLDivElement | undefined; - + const { nodePrimaryInput, nodePrimaryOutput } = this.resolveLink(link, containerBounds); if (!nodePrimaryInput || !nodePrimaryOutput) return []; if (this.disconnecting?.linkIndex === index) return []; @@ -419,9 +424,9 @@ export default defineComponent({ }; return iconMap[nodeName] || "NodeNodes"; }, - buildWirePathString(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): string { + buildWirePathLocations(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): { x: number; y: number }[] { const containerBounds = (this.$refs.nodesContainer as HTMLDivElement | undefined)?.getBoundingClientRect(); - if (!containerBounds) return "[error]"; + if (!containerBounds) return []; const outX = verticalOut ? outputBounds.x + outputBounds.width / 2 : outputBounds.x + outputBounds.width - 1; const outY = verticalOut ? outputBounds.y + 1 : outputBounds.y + outputBounds.height / 2; @@ -443,9 +448,17 @@ export default defineComponent({ const horizontalCurve = horizontalCurveAmount * curveLength; const verticalCurve = verticalCurveAmount * curveLength; - return `M${outConnectorX},${outConnectorY} C${verticalOut ? outConnectorX : outConnectorX + horizontalCurve},${verticalOut ? outConnectorY - verticalCurve : outConnectorY} ${ - verticalIn ? inConnectorX : inConnectorX - horizontalCurve - },${verticalIn ? inConnectorY + verticalCurve : inConnectorY} ${inConnectorX},${inConnectorY}`; + return [ + { x: outConnectorX, y: outConnectorY }, + { x: verticalOut ? outConnectorX : outConnectorX + horizontalCurve, y: verticalOut ? outConnectorY - verticalCurve : outConnectorY }, + { x: verticalIn ? inConnectorX : inConnectorX - horizontalCurve, y: verticalIn ? inConnectorY + verticalCurve : inConnectorY }, + { x: inConnectorX, y: inConnectorY }, + ]; + }, + buildWirePathString(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): string { + const locations = this.buildWirePathLocations(outputBounds, inputBounds, verticalOut, verticalIn); + if (locations.length === 0) return "[error]"; + return `M${locations[0].x},${locations[0].y} C${locations[1].x},${locations[1].y} ${locations[2].x},${locations[2].y} ${locations[3].x},${locations[3].y}`; }, createWirePath(outputPort: HTMLDivElement, inputPort: HTMLDivElement | DOMRect, verticalOut: boolean, verticalIn: boolean): [string, string] { const inputPortRect = inputPort instanceof HTMLDivElement ? inputPort.getBoundingClientRect() : inputPort; @@ -493,8 +506,16 @@ export default defineComponent({ this.transform.x -= scrollY / this.transform.scale; } }, + keydown(e: KeyboardEvent): void { + if (e.key.toLowerCase() === "escape") { + this.nodeListLocation = undefined; + document.removeEventListener("keydown", this.keydown); + } + }, // TODO: Move the event listener from the graph to the window so dragging outside the graph area (or even the browser window) works pointerDown(e: PointerEvent) { + if (this.nodeListLocation && !(e.target as HTMLElement).closest("[data-node-list]")) this.nodeListLocation = undefined; + if (e.button === 2) { const graphDiv: HTMLDivElement | undefined = (this.$refs.graph as typeof LayoutCol | undefined)?.$el; const graph = graphDiv?.getBoundingClientRect() || new DOMRect(); @@ -502,6 +523,8 @@ export default defineComponent({ x: Math.round(((e.clientX - graph.x) / this.transform.scale - this.transform.x) / GRID_SIZE), y: Math.round(((e.clientY - graph.y) / this.transform.scale - this.transform.y) / GRID_SIZE), }; + + document.addEventListener("keydown", this.keydown); return; } @@ -599,6 +622,8 @@ export default defineComponent({ } }, pointerUp(e: PointerEvent) { + const containerBounds = this.$refs.nodesContainer as HTMLDivElement | undefined; + if (!containerBounds) return; this.panning = false; if (this.disconnecting) { @@ -632,6 +657,48 @@ export default defineComponent({ } } this.editor.instance.moveSelectedNodes(this.draggingNodes.roundX, this.draggingNodes.roundY); + + // Check if this node should be inserted between two other nodes + if (this.selected.length === 1) { + const selectedNodeId = this.selected[0]; + const selectedNode = containerBounds.querySelector(`[data-node="${String(selectedNodeId)}"]`); + + // Check that neither the input or output of the selected node are already connected. + const notConnected = + this.nodeGraph.state.links.findIndex((link) => link.linkStart === selectedNodeId || (link.linkEnd === selectedNodeId && link.linkEndInputIndex === BigInt(0))) === -1; + const input = selectedNode?.querySelector(`[data-port="input"]`); + const output = selectedNode?.querySelector(`[data-port="output"]`); + + // TODO: Make sure inputs are correctly typed + if (selectedNode && notConnected && input && output) { + // Find the link that the node has been dragged on top of + const link = this.nodeGraph.state.links.find((link): boolean => { + const { nodePrimaryInput, nodePrimaryOutput } = this.resolveLink(link, containerBounds); + if (!nodePrimaryInput || !nodePrimaryOutput) return false; + + const wireCurveLocations = this.buildWirePathLocations(nodePrimaryOutput.getBoundingClientRect(), nodePrimaryInput.getBoundingClientRect(), false, false); + + const selectedNodeBounds = selectedNode.getBoundingClientRect(); + const containerBoundsBounds = containerBounds.getBoundingClientRect(); + + return this.editor.instance.rectangleIntersects( + new Float64Array(wireCurveLocations.map((loc) => loc.x)), + new Float64Array(wireCurveLocations.map((loc) => loc.y)), + selectedNodeBounds.top - containerBoundsBounds.y, + selectedNodeBounds.left - containerBoundsBounds.x, + selectedNodeBounds.bottom - containerBoundsBounds.y, + selectedNodeBounds.right - containerBoundsBounds.x + ); + }); + // If the node has been dragged on top of the link then connect it into the middle. + if (link) { + this.editor.instance.connectNodesByLink(link.linkStart, selectedNodeId, 0); + this.editor.instance.connectNodesByLink(selectedNodeId, link.linkEnd, Number(link.linkEndInputIndex)); + this.editor.instance.shiftNode(selectedNodeId); + } + } + } + this.draggingNodes = undefined; this.selectIfNotDragged = undefined; } diff --git a/frontend/wasm/Cargo.toml b/frontend/wasm/Cargo.toml index c03ada2e..4580c4e6 100644 --- a/frontend/wasm/Cargo.toml +++ b/frontend/wasm/Cargo.toml @@ -28,6 +28,7 @@ serde-wasm-bindgen = "0.4.1" js-sys = "0.3.55" wasm-bindgen-futures = "0.4.33" ron = {version = "0.8", optional = true} +bezier-rs = { path = "../../libraries/bezier-rs" } [dev-dependencies] wasm-bindgen-test = "0.3.22" diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index b2985c93..54ed2fdf 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -587,6 +587,13 @@ impl JsEditorHandle { self.dispatch(message); } + /// Shifts the node and its children to stop nodes going ontop of each other + #[wasm_bindgen(js_name = shiftNode)] + pub fn shift_node(&self, node_id: u64) { + let message = NodeGraphMessage::ShiftNode { node_id }; + self.dispatch(message); + } + /// Notifies the backend that the user disconnected a node #[wasm_bindgen(js_name = disconnectNodes)] pub fn disconnect_nodes(&self, node_id: u64, input_index: usize) { @@ -594,6 +601,18 @@ impl JsEditorHandle { self.dispatch(message); } + /// Check for intersections between the curve and a rectangle defined by opposite corners + #[wasm_bindgen(js_name = rectangleIntersects)] + pub fn rectangle_intersects(&self, bezier_x: Vec, bezier_y: Vec, top: f64, left: f64, bottom: f64, right: f64) -> bool { + let bezier = bezier_rs::Bezier::from_cubic_dvec2( + (bezier_x[0], bezier_y[0]).into(), + (bezier_x[1], bezier_y[1]).into(), + (bezier_x[2], bezier_y[2]).into(), + (bezier_x[3], bezier_y[3]).into(), + ); + !bezier.rectangle_intersections((left, top).into(), (right, bottom).into()).is_empty() || bezier.is_contained_within((left, top).into(), (right, bottom).into()) + } + /// Creates a new document node in the node graph #[wasm_bindgen(js_name = createNode)] pub fn create_node(&self, node_type: String, x: i32, y: i32) { diff --git a/node-graph/graph-craft/src/document.rs b/node-graph/graph-craft/src/document.rs index 9d24a348..91488a2b 100644 --- a/node-graph/graph-craft/src/document.rs +++ b/node-graph/graph-craft/src/document.rs @@ -180,6 +180,19 @@ impl NodeNetwork { .collect(); } + /// Collect a hashmap of nodes with a list of the nodes that use it as input + pub fn collect_outwards_links(&self) -> HashMap> { + let mut outwards_links: HashMap> = HashMap::new(); + for (node_id, node) in &self.nodes { + for input in &node.inputs { + if let NodeInput::Node(ref_id) = input { + outwards_links.entry(*ref_id).or_default().push(*node_id) + } + } + } + outwards_links + } + pub fn flatten(&mut self, node: NodeId) { self.flatten_with_fns(node, merge_ids, generate_uuid) }