export class CurveEditor { constructor(canvas, timelineLength = 6, _slider) { this.canvas = canvas; canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; this.ctx = canvas.getContext('2d'); this.timelineLength = timelineLength; this.scale = 1; this.scaleX = 1; this.scaleY = 1; this.offset = { x: 0, y: 0 }; this.pixelsPerSecond = 48; this.exportRange = [0, 4095]; this.currentTime = timelineLength * this.pixelsPerSecond / 2; this.motorButtons = { width: 30, height: 30, padding: 10, buttons: [] // will be populated dynamically }; // COMMENT OUT AND READ FROM EXISTING TIMELINE LATER const slider = _slider;//document.getElementById('timeSlider'); slider.max = this.timelineLength * this.pixelsPerSecond; console.log(slider.max); slider.value = 0; slider.addEventListener('input', () => { this.currentTime = parseFloat(slider.value); //console.log(slider.value); this.draw(); }); this.curveSets = {}; // motorID โ†’ array of curves this.selectedMotorID = 4; this.curves = []; this.dragging = null; this.isPanning = false; this.panStart = { x: 0, y: 0 }; // Default curve // this.setCurves([ // { // startPoint: { x: this.valueToX(0), y: this.valueToY(0) }, // startPointHandle: { x: this.valueToX(timelineLength / 2), y: this.valueToY(0.5) }, // endPointHandle: { x: this.valueToX(timelineLength / 2), y: this.valueToY(-0.5) }, // endPoint: { x: this.valueToX(timelineLength), y: this.valueToY(0) } // } // ]); // this.curveSets[this.selectedMotorID] = this.curves; // let othercurves = [ // { // startPoint: { x: this.valueToX(0), y: this.valueToY(.3) }, // startPointHandle: { x: this.valueToX(timelineLength / 2), y: this.valueToY(0.5) }, // endPointHandle: { x: this.valueToX(timelineLength / 2), y: this.valueToY(-0.5) }, // endPoint: { x: this.valueToX(timelineLength), y: this.valueToY(0) } // } // ] // this.curveSets[5] = othercurves; // othercurves = [ // { // startPoint: { x: this.valueToX(0), y: this.valueToY(-.5) }, // startPointHandle: { x: this.valueToX(timelineLength / 2), y: this.valueToY(0.5) }, // endPointHandle: { x: this.valueToX(timelineLength / 2), y: this.valueToY(-0.5) }, // endPoint: { x: this.valueToX(timelineLength), y: this.valueToY(0) } // } // ] // this.curveSets[7] = othercurves; //this.setSelectedMotor(4); this.initEvents(); this.draw(); console.log(this.curveSets); } encodeCurves() { const bufferSize = 1024; // adjust based on expected graph size const buffer = new ArrayBuffer(bufferSize); const view = new DataView(buffer); let offset = 0; const curveSegments = []; Object.entries(this.curveSets).forEach(([motorIDStr, segments]) => { const motorID = parseInt(motorIDStr, 10); segments.forEach(segment => { curveSegments.push({ motorID, startTime: segment.startPoint.x, endTime: segment.endPoint.x, startPointY: segment.startPoint.y, startHandleX: segment.startPointHandle.x, startHandleY: segment.startPointHandle.y, endHandleX: segment.endPointHandle.x, endHandleY: segment.endPointHandle.y, endPointY: segment.endPoint.y }); }); }); console.log(curveSegments); const curveCount = curveSegments.length; view.setUint16(offset, curveCount, true); offset += 2; // ๐Ÿ”น Curve segments curveSegments.forEach(seg => { console.log(offset, seg.motorID); view.setUint8(offset++, seg.motorID); view.setUint16(offset, seg.startTime, true); offset += 2; view.setUint16(offset, seg.endTime, true); offset += 2; view.setInt16(offset, this.yToExportRange(seg.startPointY), true); offset += 2; view.setUint16(offset, seg.startHandleX, true); offset += 2; view.setInt16(offset, this.yToExportRange(seg.startHandleY), true); offset += 2; view.setUint16(offset, seg.endHandleX, true); offset += 2; view.setInt16(offset, this.yToExportRange(seg.endHandleY), true); offset += 2; view.setInt16(offset, this.yToExportRange(seg.endPointY), true); offset += 2; console.log(this.yToExportRange(seg.startPointY), this.yToExportRange(seg.endPointY)); }); //console.log("๐Ÿงต Curve segments packed:", curveSegments.length); //console.log(curveSegments); return new Uint8Array(buffer.slice(0, offset)); } addChannel(motorID) { this.setCurves([ { startPoint: { x: 0, y: this.canvas.height / 2 }, startPointHandle: { x: this.timelineLength * this.pixelsPerSecond * 0.25, y: this.canvas.height / 2 }, endPointHandle: { x: this.timelineLength * this.pixelsPerSecond * 0.75, y: this.canvas.height / 2 }, endPoint: { x: this.timelineLength * this.pixelsPerSecond, y: this.canvas.height / 2 } } ]); console.log("TL LENGTH: " + this.timelineLength); this.curveSets[motorID] = this.curves; } setLength(endTime) { this.timelineLength = endTime / this.pixelsPerSecond; console.log("new endtime: " + endTime); //this.currentTime = this.timelineLength * this.pixelsPerSecond / 2; } loadCurveSets(curveSets) { this.curveSets = [] this.curveSets = curveSets; // If selectedMotorID is present in the new set, load its curves if (curveSets[this.selectedMotorID]) { this.setCurves(curveSets[this.selectedMotorID]); } else { this.setCurves([]); // fallback to empty } console.log("LOADED"); console.log() setSelectedMotor(10); // Global defined in script.js //this.selectAdjacentMotor(1); // Optional: update motor selector UI or redraw timeline //this.refreshMotorSelector?.(); // if you have a method for that //this.drawTimelineMarkers?.(); } selectMotor(motorID) { this.selectedMotorID = motorID; this.curves = this.curveSets[motorID] || []; this.draw(); } valueToY(v) { return (this.canvas.height / 2 - v * (this.canvas.height / 2)) * this.scaleY + this.offset.y; } // Maps pixel value of y axis to -1 to 1 normalised value yToValue(y) { return ((this.canvas.height / 2 - (y - this.offset.y) / this.scaleY) / (this.canvas.height / 2)); } // Maps normalised -1 to 1 value to motor range (0, 4095) yToExportRange(y) { const [minOut, maxOut] = this.exportRange; // Normalize y from [-1, 1] to [0, 1] const normalized = (this.yToValue(y) + 1) / 2; // Scale to export range return Math.round(normalized * (maxOut - minOut) + minOut); } exportRangeToY(value) { const [minOut, maxOut] = this.exportRange; // Reverse the scaling const normalized = (value - minOut) / (maxOut - minOut); // Convert from [0, 1] back to [-1, 1] const yValue = normalized * 2 - 1; // Apply inverse mapping from value space to editor Y return this.valueToY(yValue); } valueToX(value) { return value * this.pixelsPerSecond * this.scaleX + this.offset.x; } xToValue(x) { return (x - this.offset.x) / (this.pixelsPerSecond * this.scaleX); } transform(p) { return { x: p.x * this.scaleX + this.offset.x, y: p.y * this.scaleY + this.offset.y }; } inverseTransform(p) { return { x: (p.x - this.offset.x) / this.scaleX, y: (p.y - this.offset.y) / this.scaleY }; } draw() { const ctx = this.ctx; ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.drawGrid(ctx); // Draw inactive curves dimmed for (const [motorID, curves] of Object.entries(this.curveSets)) { const isSelected = motorID == this.selectedMotorID; this.drawCurves(this.ctx, curves, isSelected ? 1.0 : 0.3, isSelected); } this.drawCurves(ctx, this.curves); this.drawMotorButtons(); } drawMotorButtons() { const ctx = this.ctx; const canvas = this.canvas; const { width, height, padding } = this.motorButtons; const y = canvas.height - height - padding; // Start from the right edge and move left const xRight = canvas.width - width - padding; const xMid = xRight - (width + padding); const xLeft = xMid - (width + padding); this.motorButtons.buttons = [ { label: "<", x: xLeft, y, motorOffset: -1 }, { label: `${this.selectedMotorID}`, x: xMid, y, motorOffset: 0 }, { label: ">", x: xRight, y, motorOffset: 1 } ]; for (let btn of this.motorButtons.buttons) { ctx.fillStyle = "#eee"; ctx.fillRect(btn.x, btn.y, width, height); ctx.strokeStyle = "#333"; ctx.strokeRect(btn.x, btn.y, width, height); ctx.fillStyle = "#000"; ctx.font = "16px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(btn.label, btn.x + width / 2, btn.y + height / 2); } } cubicBezierX(t, p0, h0, h1, p1) { return Math.pow(1 - t, 3) * p0.x + 3 * Math.pow(1 - t, 2) * t * h0.x + 3 * (1 - t) * Math.pow(t, 2) * h1.x + Math.pow(t, 3) * p1.x; } solveTForX(targetX, P0, P1, P2, P3, epsilon = 0.5) { let lower = 0; let upper = 1; let t; for (let i = 0; i < 20; i++) { t = (lower + upper) / 2; const u = 1 - t; const x = u ** 3 * P0.x + 3 * u ** 2 * t * P1.x + 3 * u * t ** 2 * P2.x + t ** 3 * P3.x; if (Math.abs(x - targetX) < epsilon) { return t; } if (x < targetX) { lower = t; } else { upper = t; } } return t; } getMotorPositionAtTime(motorID, timeInFrames) { if (this.curveSets[motorID] === undefined || this.curveSets[motorID].length === 0) { console.log("THIS"); return null; } const curves = this.curveSets[motorID]; const timeInSeconds = timeInFrames / this.framesPerSecond; const currentTimeX = timeInFrames; for (let curve of curves) { const p0 = this.transform(curve.startPoint); const h0 = this.transform(curve.startPointHandle); const h1 = this.transform(curve.endPointHandle); const p1 = this.transform(curve.endPoint); const minX = Math.min(p0.x, p1.x); const maxX = Math.max(p0.x, p1.x); if (currentTimeX >= minX && currentTimeX <= maxX) { const t = this.solveTForX(currentTimeX, p0, h0, h1, p1); const pt = this.cubicBezier(t, p0, h0, h1, p1); return this.yToExportRange(pt.y); } } return null; // No curve segment matched the time } drawCurves(ctx, curves, opacity = 1.0, showControlPoints = true) { ctx.save(); ctx.globalAlpha = opacity; for (let curve of curves) { const p0 = this.transform(curve.startPoint); const h0 = this.transform(curve.startPointHandle); const h1 = this.transform(curve.endPointHandle); const p1 = this.transform(curve.endPoint); // Draw curve ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.bezierCurveTo(h0.x, h0.y, h1.x, h1.y, p1.x, p1.y); ctx.strokeStyle = "#0077cc"; ctx.lineWidth = 2; ctx.stroke(); const seconds = this.currentTime / 48; // convert to seconds const currentTimeX = this.valueToX(seconds); if (this.currentTime !== null) { for (let curve of curves) { const p0 = this.transform(curve.startPoint); const h0 = this.transform(curve.startPointHandle); const h1 = this.transform(curve.endPointHandle); const p1 = this.transform(curve.endPoint); const minX = Math.min(p0.x, p1.x); const maxX = Math.max(p0.x, p1.x); if (currentTimeX >= minX && currentTimeX <= maxX) { const t = this.solveTForX(currentTimeX, p0, h0, h1, p1); const pt = this.cubicBezier(t, p0, h0, h1, p1); ctx.beginPath(); ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2); ctx.fillStyle = "#00cc66"; ctx.fill(); ctx.strokeStyle = "#003300"; ctx.stroke(); } } } // Draw control points (optional) if (showControlPoints) { // Draw handles ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(h0.x, h0.y); ctx.moveTo(h1.x, h1.y); ctx.lineTo(p1.x, p1.y); ctx.strokeStyle = "#aaa"; ctx.lineWidth = 1; ctx.stroke(); for (let key of ['startPoint', 'startPointHandle', 'endPointHandle', 'endPoint']) { const p = this.transform(curve[key]); ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, Math.PI * 2); ctx.fillStyle = key.includes('Handle') ? "#888" : "#ff6600"; ctx.fill(); ctx.strokeStyle = "#333"; ctx.stroke(); } } } ctx.restore(); } drawGrid(ctx) { ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); const totalTicks = this.timelineLength * 4; for (let s = 0; s <= totalTicks; s++) { const time = s / 4; const x = this.valueToX(time); if (x < 0 || x > this.canvas.width) continue; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, this.canvas.height); if (s % 4 === 0) { ctx.strokeStyle = "#999"; ctx.lineWidth = 1.5; ctx.font = "12px sans-serif"; ctx.fillStyle = "#666"; ctx.fillText(`${time}s`, x + 0 / this.scaleX, 12 / this.scaleY); } else if (s % 2 === 0) { ctx.strokeStyle = "#bbb"; ctx.lineWidth = 1; } else { ctx.strokeStyle = "#ddd"; ctx.lineWidth = 0.5; } ctx.stroke(); } const visibleYMin = Math.max(this.yToValue(this.canvas.height), -1); const visibleYMax = Math.min(this.yToValue(0), 1); for (let v = Math.ceil(visibleYMin * 4) / 4; v <= visibleYMax; v += 0.25) { const y = this.valueToY(v); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(this.canvas.width, y); ctx.strokeStyle = (v === 1 || v === -1 || v === 0) ? "#999" : "#ddd"; ctx.lineWidth = (v === 1 || v === -1 || v === 0) ? 1.5 : 0.5; ctx.stroke(); ctx.font = "12px sans-serif"; ctx.fillStyle = "#666"; ctx.fillText(v.toFixed(2), 4 / this.scaleX + 10, y - 6 / this.scaleY); } } cubicBezier(t, P0, P1, P2, P3) { const u = 1 - t; return { x: u ** 3 * P0.x + 3 * u ** 2 * t * P1.x + 3 * u * t ** 2 * P2.x + t ** 3 * P3.x, y: u ** 3 * P0.y + 3 * u ** 2 * t * P1.y + 3 * u * t ** 2 * P2.y + t ** 3 * P3.y }; } splitCurve(curve, t) { const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }); const p01 = lerp(curve.startPoint, curve.startPointHandle, t); const p12 = lerp(curve.startPointHandle, curve.endPointHandle, t); const p23 = lerp(curve.endPointHandle, curve.endPoint, t); const p012 = lerp(p01, p12, t); const p123 = lerp(p12, p23, t); const split = lerp(p012, p123, t); return [ { startPoint: curve.startPoint, startPointHandle: p01, endPointHandle: p012, endPoint: split }, { startPoint: split, startPointHandle: p123, endPointHandle: p23, endPoint: curve.endPoint } ]; } setCurves(curves) { this.curves = curves; this.draw(); } getCurves() { return this.curves; } selectAdjacentMotor(direction) { const ids = Object.keys(this.curveSets) .map(id => parseInt(id)) .sort((a, b) => a - b); const currentIndex = ids.indexOf(this.selectedMotorID); if (currentIndex === -1) return; const newIndex = (currentIndex + direction + ids.length) % ids.length; setSelectedMotor(ids[newIndex]); // Global defined in script.js } initEvents() { this.canvas.addEventListener('mousedown', e => { const { buttons, width, height } = this.motorButtons; const mx = e.offsetX; const my = e.offsetY; for (let btn of buttons) { if ( mx >= btn.x && mx <= btn.x + width && my >= btn.y && my <= btn.y + height ) { if (btn.motorOffset === -1) { this.selectAdjacentMotor(-1); } else if (btn.motorOffset === 1) { this.selectAdjacentMotor(1); } console.log(this.selectedMotorID); // No action needed for label button (motorOffset === 0) return; } } const mouse = this.inverseTransform({ x: e.offsetX, y: e.offsetY }); if (e.button === 1) { e.preventDefault(); // Prevent page panning this.isPanning = true; this.panStart = { x: e.offsetX, y: e.offsetY }; return; } for (let curve of this.curves) { for (let key of ['startPoint', 'startPointHandle', 'endPointHandle', 'endPoint']) { const p = curve[key]; if (Math.hypot(p.x - mouse.x, p.y - mouse.y) < 10 / this.scale) { this.dragging = { curve, key }; return; } } } }); this.canvas.addEventListener('mousemove', e => { if (this.isPanning) { this.offset.x += e.offsetX - this.panStart.x; this.offset.y += e.offsetY - this.panStart.y; this.panStart = { x: e.offsetX, y: e.offsetY }; this.draw(); return; } if (this.dragging) { const mouse = this.inverseTransform({ x: e.offsetX, y: e.offsetY }); const { curve, key } = this.dragging; const index = this.curves.indexOf(curve); if (key === 'startPoint' || key === 'endPoint') { this.dragEndpoint(curve, key, mouse.x, mouse.y, index); } else { this.dragControlPoint(curve, key, mouse.x, mouse.y, index); } this.curveSets[this.selectedMotorID] = this.curves; this.draw(); } }); this.canvas.addEventListener('mouseup', () => { this.dragging = null; this.isPanning = false; }); this.canvas.addEventListener('wheel', e => { e.preventDefault(); const mouse = { x: e.offsetX, y: e.offsetY }; const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; const before = this.inverseTransform(mouse); // Apply zoom based on modifier keys if (e.ctrlKey && !e.altKey) { this.scaleX *= zoomFactor; } else if (e.altKey && !e.ctrlKey) { this.scaleY *= zoomFactor; } else { // Default: uniform zoom this.scaleX *= zoomFactor; this.scaleY *= zoomFactor; } const after = this.inverseTransform(mouse); this.offset.x += (after.x - before.x) * this.scaleX; this.offset.y += (after.y - before.y) * this.scaleY; this.draw(); }); this.canvas.addEventListener('dblclick', e => { const mouse = this.inverseTransform({ x: e.offsetX, y: e.offsetY }); for (let i = 0; i < this.curves.length; i++) { const curve = this.curves[i]; if (i > 0 && this.isNearPoint(mouse, curve.startPoint)) { const prev = this.curves[i - 1]; const merged = { startPoint: prev.startPoint, startPointHandle: prev.startPointHandle, endPointHandle: curve.endPointHandle, endPoint: curve.endPoint }; this.curves.splice(i - 1, 2, merged); this.draw(); this.curveSets[this.selectedMotorID] = this.curves; return; } if (i < this.curves.length - 1 && this.isNearPoint(mouse, curve.endPoint)) { const next = this.curves[i + 1]; const merged = { startPoint: curve.startPoint, startPointHandle: curve.startPointHandle, endPointHandle: next.endPointHandle, endPoint: next.endPoint }; this.curves.splice(i, 2, merged); this.draw(); this.curveSets[this.selectedMotorID] = this.curves; return; } const x0 = curve.startPoint.x; const x3 = curve.endPoint.x; if (mouse.x >= x0 && mouse.x <= x3) { let t = 0.5, minT = 0, maxT = 1; for (let j = 0; j < 10; j++) { const pt = this.cubicBezier(t, curve.startPoint, curve.startPointHandle, curve.endPointHandle, curve.endPoint); if (pt.x < mouse.x) minT = t; else maxT = t; t = (minT + maxT) / 2; } const [left, right] = this.splitCurve(curve, t); this.curves.splice(i, 1, left, right); this.draw(); this.curveSets[this.selectedMotorID] = this.curves; return; } } }); } dragControlPoint(curve, key, mouseX, mouseY, index) { curve[key].y = mouseY; const firstX = this.curves[0].startPoint.x; const lastX = this.curves[this.curves.length - 1].endPoint.x; if (key === 'startPointHandle') { const minX = Math.max(curve.startPoint.x, this.curves[index - 1]?.endPoint?.x ?? firstX); const maxX = this.curves[index + 1]?.startPoint?.x ?? lastX; curve.startPointHandle.x = Math.max(minX, Math.min(mouseX, maxX - 0.01)); } else if (key === 'endPointHandle') { const minX = this.curves[index - 1]?.endPoint?.x ?? firstX; const maxX = Math.min(curve.endPoint.x, this.curves[index + 1]?.endPoint?.x ?? lastX); curve.endPointHandle.x = Math.max(minX, Math.min(mouseX, maxX - 0.01)); } else { curve[key].x = Math.max(firstX, Math.min(mouseX, lastX)); } } dragEndpoint(curve, key, mouseX, mouseY, index) { let deltaX = 0; let deltaY = 0; if (key === 'startPoint') { const oldX = curve.startPoint.x; const oldY = curve.startPoint.y; if (index === 0) { curve.startPoint.x = 0; } else { const firstX = this.curves[0].startPoint.x; const nextX = this.curves[index + 1]?.startPoint?.x ?? Infinity; curve.startPoint.x = Math.max(firstX + 0.01, Math.min(mouseX, nextX - 0.01)); } curve.startPoint.y = mouseY; deltaX = curve.startPoint.x - oldX; deltaY = curve.startPoint.y - oldY; curve.startPointHandle.x += deltaX; curve.startPointHandle.y += deltaY; this.dragControlPoint(curve, 'startPointHandle', curve.startPointHandle.x, curve.startPointHandle.y, index); //console.log(this.yToExportRange(curve.startPoint.y)); } else if (key === 'endPoint') { const oldX = curve.endPoint.x; const oldY = curve.endPoint.y; if (index === this.curves.length - 1) { curve.endPoint.x = this.timelineLength * this.pixelsPerSecond; // logical time, not screen space } else { const firstX = this.curves[0].startPoint.x; const prevX = this.curves[index - 1]?.endPoint?.x ?? firstX; const nextX = this.curves[index + 1]?.endPoint?.x ?? Infinity; curve.endPoint.x = Math.max(prevX + 0.01, Math.min(mouseX, nextX - 0.01)); } curve.endPoint.y = mouseY; deltaX = curve.endPoint.x - oldX; deltaY = curve.endPoint.y - oldY; curve.endPointHandle.x += deltaX; curve.endPointHandle.y += deltaY; this.dragControlPoint(curve, 'endPointHandle', curve.endPointHandle.x, curve.endPointHandle.y, index); const nextCurve = this.curves[index + 1]; //console.log(curve.endPoint.y); if (nextCurve) { nextCurve.startPoint.x = curve.endPoint.x; nextCurve.startPoint.y = curve.endPoint.y; nextCurve.startPointHandle.x += deltaX; nextCurve.startPointHandle.y += deltaY; this.dragControlPoint(nextCurve, 'startPointHandle', nextCurve.startPointHandle.x, nextCurve.startPointHandle.y, index + 1); } } // After all endpoint logic is done for (let i = 0; i < this.curves.length; i++) { const curve = this.curves[i]; this.dragControlPoint(curve, 'startPointHandle', curve.startPointHandle.x, curve.startPointHandle.y, i); this.dragControlPoint(curve, 'endPointHandle', curve.endPointHandle.x, curve.endPointHandle.y, i); } } isNearPoint(mouse, point, threshold = 5) { return ( Math.abs(mouse.x - point.x) < threshold && Math.abs(mouse.y - point.y) < threshold ); } }