sophia_controller/curveEditor.js

810 lines
27 KiB
JavaScript

export class CurveEditor {
constructor(canvas, timelineLength = 10, _slider) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.timelineLength = timelineLength;
this.scale = 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: this.valueToX(0), y: this.valueToY(0) },
startPointHandle: { x: this.valueToX(this.timelineLength * 0.25), y: this.valueToY(0) },
endPointHandle: { x: this.valueToX(this.timelineLength * 0.75), y: this.valueToY(0) },
endPoint: { x: this.valueToX(this.timelineLength), y: this.valueToY(0) }
}
]);
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.scale + 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.scale) / (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(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;
}
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 + 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;
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) {
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
);
}
}