619 lines
19 KiB
JavaScript
619 lines
19 KiB
JavaScript
|
||
import { ServoNode, CurveNode, VariableNode, NoiseNode, MathNode, MapNode, NODE_TYPES } from "./nodes.js"
|
||
|
||
|
||
export class NodeEditor {
|
||
constructor(canvas, options = {}) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext("2d");
|
||
this.nodes = [];
|
||
this.connections = [];
|
||
this.draggingNode = null;
|
||
this.draggingWire = null;
|
||
this.motorIds = options.motorIds || []; // pass motor ID list here
|
||
|
||
this.panX = 0;
|
||
this.panY = 0;
|
||
this.isPanning = false;
|
||
this.lastPan = { x: 0, y: 0 };
|
||
this.zoom = 1.0;
|
||
this.focusedInput = null;
|
||
|
||
|
||
this._bindEvents();
|
||
this._redraw();
|
||
}
|
||
|
||
generateDefaultNodes(curveSets, motorIDs) {
|
||
console.log("Generating Default Nodes");
|
||
console.log(curveSets, motorIDs);
|
||
|
||
for (var i = 0; i < motorIDs.length; i++) {
|
||
let inputNode = this.addCurveNode(200, 20 + i * 140, "Curve", motorIDs[i]);
|
||
let outputNode = this.addServoNode(500, 40 + i * 140, "Servo Output", motorIDs[i]);
|
||
this.connections.push({ from: inputNode, to: outputNode });
|
||
}
|
||
|
||
//this.addVariableNode(50, 120, "Var");
|
||
}
|
||
|
||
getNodeByID(id) {
|
||
return this.nodes.find(node => node.id === id) || null;
|
||
}
|
||
|
||
getAllInputs() {
|
||
let inputs = [];
|
||
for (var i = 0; i < this.nodes.length; i++) {
|
||
inputs.push(...this.nodes[i].inputs);
|
||
}
|
||
return inputs;
|
||
}
|
||
|
||
encodeNodeGraph() {
|
||
const bufferSize = 1024; // adjust based on expected graph size
|
||
const buffer = new ArrayBuffer(bufferSize);
|
||
const view = new DataView(buffer);
|
||
let offset = 0;
|
||
|
||
const nodes = this.nodes;
|
||
|
||
// Node count (1 byte)
|
||
view.setUint8(offset++, nodes.length);
|
||
|
||
// Encode nodes
|
||
nodes.forEach((node, index) => {
|
||
const typeCode = NODE_TYPES[node.type];
|
||
node.id = index;
|
||
|
||
view.setUint8(offset++, node.type); // Node type
|
||
view.setUint8(offset++, node.id); // Node ID
|
||
view.setUint16(offset, node.x, true); offset += 2;
|
||
view.setUint16(offset, node.y, true); offset += 2;
|
||
|
||
|
||
switch (node.type) {
|
||
case NODE_TYPES.Servo:
|
||
view.setUint8(offset++, node.motorId);
|
||
break;
|
||
|
||
case NODE_TYPES.Curve:
|
||
view.setUint8(offset++, node.curveId);
|
||
break;
|
||
|
||
case NODE_TYPES.Noise:
|
||
view.setFloat32(offset, node.inputFrequency.numericValue, true); offset += 4;
|
||
view.setUint16(offset, node.inputSeed.numericValue); offset += 2;
|
||
break;
|
||
|
||
|
||
case NODE_TYPES.Variable:
|
||
view.setUint8(offset++, node.variableDropdown.selectedIndex);
|
||
view.setUint8(offset++, node.arg0Input.numericValue);
|
||
break;
|
||
|
||
|
||
case NODE_TYPES.Math:
|
||
view.setUint8(offset++, node.operatorDropdown.selectedIndex);
|
||
view.setFloat32(offset, node.valueInput.numericValue, true); offset += 4;
|
||
break;
|
||
|
||
case NODE_TYPES.Map:
|
||
view.setFloat32(offset, node.inMinInput.numericValue, true); offset += 4;
|
||
view.setFloat32(offset, node.inMaxInput.numericValue, true); offset += 4;
|
||
view.setFloat32(offset, node.outMinInput.numericValue, true); offset += 4;
|
||
view.setFloat32(offset, node.outMaxInput.numericValue, true); offset += 4;
|
||
console.log(node.inMinInput.numericValue, node.inMaxInput.numericValue, node.outMinInput.numericValue, node.outMaxInput.numericValue);
|
||
break;
|
||
|
||
default:
|
||
console.warn("Unknown node type:", node.type);
|
||
}
|
||
});
|
||
|
||
// Connection count (1 byte)
|
||
view.setUint8(offset++, this.connections.length);
|
||
|
||
// Encode connections
|
||
this.connections.forEach(conn => {
|
||
view.setUint8(offset++, conn.from.id);
|
||
view.setUint8(offset++, conn.to.id);
|
||
});
|
||
|
||
// Slice the buffer to actual used size
|
||
return new Uint8Array(buffer.slice(0, offset));
|
||
}
|
||
|
||
loadFromBinary(data) {
|
||
this.nodes = []
|
||
console.log(this.connections);
|
||
this.connections = []
|
||
const view = new DataView(data.buffer);
|
||
let offset = 0;
|
||
|
||
const nodeCount = view.getUint8(offset++);
|
||
const idMap = {}; // Map node IDs to actual node instances
|
||
|
||
for (let i = 0; i < nodeCount; i++) {
|
||
const type = view.getUint8(offset++);
|
||
const id = view.getUint8(offset++);
|
||
const x = view.getUint16(offset, true); offset += 2;
|
||
const y = view.getUint16(offset, true); offset += 2;
|
||
|
||
let node = null;
|
||
|
||
switch (type) {
|
||
case NODE_TYPES.Servo: {
|
||
const motorID = view.getUint8(offset++);
|
||
node = this.addServoNode(x, y, "Servo Output", motorID);
|
||
break;
|
||
}
|
||
case NODE_TYPES.Curve: {
|
||
const curveID = view.getUint8(offset++);
|
||
node = this.addCurveNode(x, y, "Curve", curveID);
|
||
break;
|
||
}
|
||
case NODE_TYPES.Noise: {
|
||
const frequency = view.getFloat32(offset, true); offset += 4;
|
||
const seed = view.getUint16(offset, true); offset += 2;
|
||
node = this.addNoiseNode(x, y, "Noise");
|
||
node.inputFrequency.value = frequency;
|
||
node.inputSeed.value = seed;
|
||
break;
|
||
}
|
||
case NODE_TYPES.Variable: {
|
||
const source = view.getUint8(offset++);
|
||
const arg0 = view.getUint8(offset++);
|
||
node = this.addVariableNode(x, y, "Variable");
|
||
node.variableDropdown.selectedIndex = source;
|
||
node.arg0Input.value = arg0;
|
||
break;
|
||
}
|
||
case NODE_TYPES.Math: {
|
||
const op = view.getUint8(offset++);
|
||
const value = view.getFloat32(offset, true); offset += 4;
|
||
node = this.addMathNode(x, y, "Math");
|
||
console.log(op, value);
|
||
node.operatorDropdown.selectedIndex = op;
|
||
node.valueInput.value = value;
|
||
break;
|
||
}
|
||
case NODE_TYPES.Map: {
|
||
const inMin = view.getFloat32(offset, true); offset += 4;
|
||
const inMax = view.getFloat32(offset, true); offset += 4;
|
||
const outMin = view.getFloat32(offset, true); offset += 4;
|
||
const outMax = view.getFloat32(offset, true); offset += 4;
|
||
node = this.addMapNode(x, y, "Map");
|
||
node.inMinInput.value = inMin;
|
||
node.inMaxInput.value = inMax;
|
||
node.outMinInput.value = outMin;
|
||
node.outMaxInput.value = outMax;
|
||
break;
|
||
}
|
||
default: {
|
||
node = this.addNode(x, y, "Unknown Node");
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (node) {
|
||
node.id = id;
|
||
idMap[id] = node;
|
||
}
|
||
}
|
||
|
||
console.log(this.getNodeByID(0));
|
||
|
||
// 🔗 Load connections
|
||
this.connection = [];
|
||
const connectionCount = view.getUint8(offset++);
|
||
for (let i = 0; i < connectionCount; i++) {
|
||
const fromID = view.getUint8(offset++);
|
||
const toID = view.getUint8(offset++);
|
||
const fromNode = idMap[fromID];
|
||
const toNode = idMap[toID];
|
||
if (fromNode && toNode) {
|
||
this.connections.push({ from: fromNode, to: toNode });
|
||
}
|
||
}
|
||
|
||
|
||
// Reencode all positions to SIGNED ints
|
||
this.nodes.forEach(node => {
|
||
node.x = (node.x << 16) >> 16;
|
||
node.y = (node.y << 16) >> 16;
|
||
});
|
||
|
||
this._redraw();
|
||
}
|
||
|
||
|
||
|
||
addNode(x, y, label, options = {}) {
|
||
const node = new Node(x, y, label, options);
|
||
this.nodes.push(node);
|
||
this._redraw();
|
||
return node;
|
||
}
|
||
|
||
addServoNode(x, y, label = "Servo Output", motorID) {
|
||
//console.log(motorID);
|
||
const node = new ServoNode(x, y, label, motorID);
|
||
this.nodes.push(node);
|
||
this._redraw();
|
||
return node;
|
||
}
|
||
|
||
addCurveNode(x, y, label = "Curve", motorID) {
|
||
//console.log(motorID);
|
||
const node = new CurveNode(x, y, label, motorID);
|
||
this.nodes.push(node);
|
||
this._redraw();
|
||
return node;
|
||
}
|
||
|
||
addInputNode(x, y, label = "Input Node", options = { defaultValue: 1.0 }) {
|
||
const node = new InputNode(x, y, label, options);
|
||
this.nodes.push(node);
|
||
this._redraw();
|
||
return node;
|
||
}
|
||
|
||
addNoiseNode(x, y, label = "Noise Generator", options = {}) {
|
||
const node = new NoiseNode(x, y, label, options);
|
||
this.nodes.push(node);
|
||
this._redraw();
|
||
return node;
|
||
}
|
||
|
||
addVariableNode(x, y, label = "Variable") {
|
||
const node = new VariableNode(x, y, label);
|
||
this.nodes.push(node);
|
||
this._redraw();
|
||
return node;
|
||
}
|
||
|
||
addMathNode(x, y, label = "Math") {
|
||
const node = new MathNode(x, y, label);
|
||
this.nodes.push(node);
|
||
this._redraw();
|
||
return node;
|
||
}
|
||
|
||
addMapNode(x, y, label = "Map") {
|
||
const node = new MapNode(x, y, label);
|
||
this.nodes.push(node);
|
||
this._redraw();
|
||
return node;
|
||
}
|
||
|
||
|
||
|
||
|
||
_bindEvents() {
|
||
this.canvas.addEventListener("mousedown", (e) => this._onMouseDown(e));
|
||
this.canvas.addEventListener("mousemove", (e) => this._onMouseMove(e));
|
||
this.canvas.addEventListener("mouseup", (e) => this._onMouseUp(e));
|
||
this.canvas.addEventListener("dblclick", (e) => this._onDoubleClick(e));
|
||
window.addEventListener("keydown", (e) => this._onKeyDown(e));
|
||
this.canvas.addEventListener("wheel", (e) => this._onWheel(e));
|
||
|
||
this.canvas.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
const { x, y } = this._getMouse(e); // canvas-space for node placement
|
||
this._showContextMenu(e.pageX, e.pageY, x, y); // page-space for menu placement
|
||
});
|
||
|
||
|
||
|
||
|
||
}
|
||
|
||
|
||
_onMouseDown(e) {
|
||
const { x, y } = this._getMouse(e);
|
||
|
||
if (e.button === 1) { // middle mouse
|
||
this.isPanning = true;
|
||
console.log("pan")
|
||
this.lastPan = this._getMouse(e);
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
|
||
this.focusedInput = null;
|
||
let allInputs = this.getAllInputs();
|
||
for (const input of allInputs) {
|
||
if (input.handleClick(x, y)) {
|
||
this.focusedInput = input;
|
||
} else {
|
||
input.focused = false;
|
||
}
|
||
}
|
||
|
||
|
||
for (const node of this.nodes) {
|
||
if (node.contains(x, y)) {
|
||
if (node.handleClick(x, y)) {
|
||
this._redraw();
|
||
return;
|
||
}
|
||
|
||
this.draggingNode = node;
|
||
node.offsetX = x - node.x;
|
||
node.offsetY = y - node.y;
|
||
return;
|
||
}
|
||
|
||
if (node.hitOutput(x, y)) {
|
||
this.draggingWire = { x1: node.output.x, y1: node.output.y, x2: x, y2: y, from: node };
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
_onMouseMove(e) {
|
||
const { x, y } = this._getMouse(e);
|
||
|
||
let needsRedraw = false;
|
||
|
||
if (this.isPanning) {
|
||
const { x, y } = this._getMouse(e);
|
||
const { screenX, screenY } = this._getMouse(e);
|
||
this.panX += screenX - this.lastPan.screenX;
|
||
this.panY += screenY - this.lastPan.screenY;
|
||
this.lastPan = { screenX, screenY };
|
||
|
||
this._redraw();
|
||
//return;
|
||
}
|
||
|
||
|
||
// Required for highlighting dropdown options onMouseOver
|
||
for (const node of this.nodes) {
|
||
//if (node instanceof ServoNode || node instanceof VariableNode) {
|
||
if (node.handleMouseMove(x, y)) {
|
||
needsRedraw = true;
|
||
}
|
||
//}
|
||
}
|
||
|
||
if (needsRedraw) this._redraw();
|
||
|
||
|
||
if (this.draggingNode) {
|
||
this.draggingNode.x = x - this.draggingNode.offsetX;
|
||
this.draggingNode.y = y - this.draggingNode.offsetY;
|
||
this.draggingNode.updatePorts();
|
||
this._redraw();
|
||
}
|
||
|
||
if (this.draggingWire) {
|
||
this.draggingWire.x2 = x;
|
||
this.draggingWire.y2 = y;
|
||
this._redraw();
|
||
}
|
||
}
|
||
|
||
|
||
_onMouseUp(e) {
|
||
const { x, y } = this._getMouse(e);
|
||
|
||
if (this.isPanning) {
|
||
this.isPanning = false;
|
||
}
|
||
|
||
|
||
if (this.draggingWire) {
|
||
for (const node of this.nodes) {
|
||
if (node.hitInput(x, y)) {
|
||
// Remove any existing connection to this input
|
||
for (let i = this.connections.length - 1; i >= 0; i--) {
|
||
if (this.connections[i].to === node) {
|
||
this.connections.splice(i, 1);
|
||
}
|
||
}
|
||
|
||
// Add new connection
|
||
this.connections.push({ from: this.draggingWire.from, to: node });
|
||
break;
|
||
}
|
||
}
|
||
this.draggingWire = null;
|
||
this._redraw();
|
||
}
|
||
|
||
|
||
this.draggingNode = null;
|
||
}
|
||
|
||
_onDoubleClick(e) {
|
||
const { x, y } = this._getMouse(e);
|
||
|
||
// First: check for wire deletion
|
||
for (let i = this.connections.length - 1; i >= 0; i--) {
|
||
const conn = this.connections[i];
|
||
if (this._isPointNearLine(x, y, conn.from.output.x, conn.from.output.y, conn.to.input.x, conn.to.input.y)) {
|
||
this.connections.splice(i, 1);
|
||
this._redraw();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Second: check for node deletion
|
||
for (let i = this.nodes.length - 1; i >= 0; i--) {
|
||
const node = this.nodes[i];
|
||
if (node.contains(x, y) && node.canDelete) {
|
||
// Remove connections to/from this node
|
||
this.connections = this.connections.filter(conn => conn.from !== node && conn.to !== node);
|
||
this.nodes.splice(i, 1);
|
||
this._redraw();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
_getMouse(e) {
|
||
const rect = this.canvas.getBoundingClientRect();
|
||
const screenX = e.clientX - rect.left;
|
||
const screenY = e.clientY - rect.top;
|
||
|
||
const canvasX = (screenX - this.panX) / this.zoom;
|
||
const canvasY = (screenY - this.panY) / this.zoom;
|
||
|
||
return {
|
||
x: canvasX,
|
||
y: canvasY,
|
||
screenX,
|
||
screenY
|
||
};
|
||
}
|
||
|
||
|
||
_onKeyDown(e) {
|
||
let needsRedraw = false;
|
||
|
||
for (const node of this.nodes) {
|
||
if (node.handleKey && node.handleKey(e)) {
|
||
needsRedraw = true;
|
||
}
|
||
}
|
||
|
||
if (needsRedraw) this._redraw();
|
||
}
|
||
|
||
|
||
_onWheel(e) {
|
||
const zoomFactor = 1.1;
|
||
if (e.deltaY < 0) {
|
||
this.zoom *= zoomFactor;
|
||
} else {
|
||
this.zoom /= zoomFactor;
|
||
}
|
||
this.zoom = Math.max(0.2, Math.min(3.0, this.zoom)); // clamp zoom
|
||
this._redraw();
|
||
e.preventDefault();
|
||
}
|
||
|
||
|
||
|
||
_isPointNearLine(px, py, x1, y1, x2, y2, threshold = 10) {
|
||
const dx = x2 - x1;
|
||
const dy = y2 - y1;
|
||
const lengthSquared = dx * dx + dy * dy;
|
||
if (lengthSquared === 0) return false;
|
||
|
||
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lengthSquared));
|
||
const closestX = x1 + t * dx;
|
||
const closestY = y1 + t * dy;
|
||
const dist = Math.hypot(px - closestX, py - closestY);
|
||
return dist < threshold;
|
||
}
|
||
|
||
_showContextMenu(screenX, screenY, canvasX, canvasY) {
|
||
const menu = document.getElementById("contextMenu");
|
||
menu.innerHTML = ""; // clear previous items
|
||
menu.style.left = `${screenX}px`;
|
||
menu.style.top = `${screenY}px`;
|
||
menu.style.display = "block";
|
||
|
||
const hideMenu = () => {
|
||
menu.style.display = "none";
|
||
window.removeEventListener("click", hideMenu);
|
||
};
|
||
window.addEventListener("click", hideMenu);
|
||
|
||
// ✅ Only include Variable node
|
||
let item = document.createElement("div");
|
||
item.className = "menu-item";
|
||
item.textContent = "➕ Add Variable Node";
|
||
item.onclick = () => {
|
||
this.addVariableNode(canvasX, canvasY, "Variable");
|
||
hideMenu();
|
||
};
|
||
menu.appendChild(item);
|
||
|
||
item = document.createElement("div");
|
||
item.className = "menu-item";
|
||
item.textContent = "➕ Add Math Node";
|
||
item.onclick = () => {
|
||
this.addMathNode(canvasX, canvasY, "Math");
|
||
hideMenu();
|
||
};
|
||
menu.appendChild(item);
|
||
|
||
item = document.createElement("div");
|
||
item.className = "menu-item";
|
||
item.textContent = "➕ Add Map Node";
|
||
item.onclick = () => {
|
||
this.addMapNode(canvasX, canvasY, "Map");
|
||
hideMenu();
|
||
};
|
||
menu.appendChild(item);
|
||
|
||
item = document.createElement("div");
|
||
item.className = "menu-item";
|
||
item.textContent = "➕ Add Noise Node";
|
||
item.onclick = () => {
|
||
this.addNoiseNode(canvasX, canvasY, "Noise");
|
||
hideMenu();
|
||
};
|
||
menu.appendChild(item);
|
||
}
|
||
|
||
|
||
|
||
_drawConnections() {
|
||
this.ctx.strokeStyle = "#444";
|
||
this.ctx.lineWidth = 2;
|
||
for (const conn of this.connections) {
|
||
const x1 = conn.from.output.x;
|
||
const y1 = conn.from.output.y;
|
||
const x2 = conn.to.input.x;
|
||
const y2 = conn.to.input.y;
|
||
|
||
const dx = Math.abs(x2 - x1) * 0.5;
|
||
|
||
this.ctx.beginPath();
|
||
this.ctx.moveTo(x1, y1);
|
||
this.ctx.bezierCurveTo(
|
||
x1 + dx, y1,
|
||
x2 - dx, y2,
|
||
x2, y2
|
||
);
|
||
this.ctx.stroke();
|
||
|
||
}
|
||
}
|
||
|
||
_redraw() {
|
||
this.ctx.save();
|
||
this.ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform
|
||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // clear full canvas
|
||
|
||
this.ctx.setTransform(this.zoom, 0, 0, this.zoom, this.panX, this.panY);
|
||
|
||
|
||
|
||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||
this._drawConnections();
|
||
for (const node of this.nodes) {
|
||
node.draw(this.ctx);
|
||
}
|
||
|
||
if (this.draggingWire) {
|
||
this.ctx.strokeStyle = "#888";
|
||
this.ctx.lineWidth = 2;
|
||
this.ctx.beginPath();
|
||
this.ctx.moveTo(this.draggingWire.x1, this.draggingWire.y1);
|
||
this.ctx.lineTo(this.draggingWire.x2, this.draggingWire.y2);
|
||
this.ctx.stroke();
|
||
}
|
||
|
||
this.ctx.restore();
|
||
}
|
||
}
|