diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6f3a291 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/curveEditor.js b/curveEditor.js new file mode 100644 index 0000000..19bc106 --- /dev/null +++ b/curveEditor.js @@ -0,0 +1,692 @@ +export class CurveEditor { + constructor(canvas, timelineLength = 10) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.timelineLength = timelineLength; + + this.scale = 1; + this.offset = { x: 0, y: 0 }; + this.pixelsPerSecond = 48; + + 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 = 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(5); + + this.initEvents(); + this.draw(); + console.log(this.curveSets); + } + + setLength(endTime){ + this.timelineLength = endTime / this.pixelsPerSecond; + console.log("new endtime: " + endTime); + //this.currentTime = this.timelineLength * this.pixelsPerSecond / 2; + } + + loadCurveSets(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"); + this.setSelectedMotor(10) + //this.selectAdjacentMotor(1); + // Optional: update motor selector UI or redraw timeline + //this.refreshMotorSelector?.(); // if you have a method for that + //this.drawTimelineMarkers?.(); + } + + + + setSelectedMotor(motorID) { + this.selectedMotorID = motorID; + this.curves = this.curveSets[motorID] || []; + this.draw(); + } + + + + valueToY(v) { + return (this.canvas.height / 2 - v * (this.canvas.height / 2)) * this.scale + this.offset.y; + } + + yToValue(y) { + return ((this.canvas.height / 2 - (y - this.offset.y) / this.scale) / (this.canvas.height / 2)); + } + + valueToX(t) { + return t * this.pixelsPerSecond * this.scale + this.offset.x; + } + + xToValue(x) { + return (x - this.offset.x) / (this.pixelsPerSecond * this.scale); + } + + transform(p) { + return { + x: p.x * this.scale + this.offset.x, + y: p.y * this.scale + this.offset.y + }; + } + + inverseTransform(p) { + return { + x: (p.x - this.offset.x) / this.scale, + y: (p.y - this.offset.y) / this.scale + }; + } + + + + 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; + } + + getPositionAtTime(timeInSeconds, curve, valueToX, transform) { + // Convert time to pixel X position + const targetX = valueToX(timeInSeconds); + + // Transform control points + const p0 = transform(curve.startPoint); + const h0 = transform(curve.startPointHandle); + const h1 = transform(curve.endPointHandle); + const p1 = transform(curve.endPoint); + + + const t = solveTForX(targetX, p0, h0, h1, p1); + return cubicBezier(t, p0, h0, h1, p1); + } + + + 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 + 2, 12); + } 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, y - 4); + } + } + + 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; + this.setSelectedMotor(ids[newIndex]); + + } + + + 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) { + 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); + this.scale *= zoomFactor; + const after = this.inverseTransform(mouse); + + this.offset.x += (after.x - before.x) * this.scale; + this.offset.y += (after.y - before.y) * this.scale; + + 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); + } 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]; + 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 + ); + } + +} diff --git a/index.html b/index.html index 7d2bc12..10acb34 100644 --- a/index.html +++ b/index.html @@ -155,6 +155,12 @@