906 lines
30 KiB
JavaScript
906 lines
30 KiB
JavaScript
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.logicalHeight = 800;
|
||
|
||
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.durationHandle = {
|
||
x: timelineLength, // or maxTimeCS
|
||
y: 30, // top of canvas
|
||
radius: 10,
|
||
type: "duration"
|
||
};
|
||
|
||
|
||
this.motorButtons = {
|
||
width: 30,
|
||
height: 30,
|
||
padding: 10,
|
||
buttons: [] // will be populated dynamically
|
||
};
|
||
|
||
|
||
// COMMENT OUT AND READ FROM EXISTING TIMELINE LATER
|
||
this.slider = _slider;//document.getElementById('timeSlider');
|
||
this.slider.max = this.timelineLength * this.pixelsPerSecond;
|
||
|
||
console.log(this.slider.max);
|
||
this.slider.value = 0;
|
||
this.slider.addEventListener('input', () => {
|
||
this.currentTime = parseFloat(this.slider.value);
|
||
//console.log(slider.value);
|
||
this.draw();
|
||
});
|
||
this.setLength(timelineLength * this.pixelsPerSecond);
|
||
|
||
|
||
this.curveSets = {}; // motorID → array of curves
|
||
this.selectedMotorID = 4;
|
||
this.curves = [];
|
||
this.dragging = null;
|
||
this.isPanning = false;
|
||
this.panStart = { x: 0, y: 0 };
|
||
|
||
|
||
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(seg.startPointY, seg.endPointY);
|
||
console.log(this.yToExportRange(seg.startPointY), this.yToExportRange(seg.endPointY));
|
||
});
|
||
//console.log("🧵 Curve segments packed:", curveSegments.length);
|
||
|
||
|
||
|
||
return new Uint8Array(buffer.slice(0, offset));
|
||
}
|
||
|
||
setCurrentTime(time) {
|
||
this.currentTime = parseFloat(time);
|
||
this.draw();
|
||
}
|
||
|
||
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;
|
||
this.slider.max = this.timelineLength * this.pixelsPerSecond;
|
||
//console.log(this.slider.max, this.slider.value);
|
||
if (this.slider.value > this.slider.max) {
|
||
this.slider.value = this.slider.max;
|
||
}
|
||
this.durationHandle.x = this.timelineLength;
|
||
//console.log("new endtime: " + endTime);
|
||
//this.currentTime = this.timelineLength * this.pixelsPerSecond / 2;
|
||
this.draw();
|
||
}
|
||
|
||
getLength(){
|
||
return this.timelineLength * this.pixelsPerSecond;
|
||
}
|
||
|
||
loadCurveSets(curveSets) {
|
||
this.curveSets = []
|
||
this.curveSets = curveSets;
|
||
console.log(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(this.curveSets);
|
||
console.log(Object.keys(curveSets)[0]);
|
||
setSelectedMotor(parseInt(Object.keys(curveSets)[0]));
|
||
// hello
|
||
|
||
//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;
|
||
// }
|
||
|
||
valueToYNoOffset(v) {
|
||
return (this.canvas.height / 2 - v * (this.canvas.height / 2));
|
||
}
|
||
|
||
valueToY(v) {
|
||
return (this.logicalHeight / 2 - v * (this.logicalHeight / 2)) * this.scaleY + this.offset.y;
|
||
}
|
||
|
||
yToValue(y) {
|
||
return ((this.logicalHeight / 2 - (y - this.offset.y) / this.scaleY) / (this.logicalHeight / 2));
|
||
}
|
||
|
||
|
||
// Maps normalised -1 to 1 value to motor range (0, 4095)
|
||
yToExportRange(y) {
|
||
const [minOut, maxOut] = this.exportRange;
|
||
const clampedY = Math.max(0, Math.min(y, this.logicalHeight)); // optional safety
|
||
const normalized = clampedY / this.logicalHeight; // maps to [0, 1]
|
||
return Math.round(normalized * (maxOut - minOut) + minOut); // maps to [minOut, maxOut]
|
||
}
|
||
|
||
exportRangeToY(value) {
|
||
const [minOut, maxOut] = this.exportRange;
|
||
const normalized = 1 - (value - minOut) / (maxOut - minOut); // flipped
|
||
return this.valueToYNoOffset(normalized * 2 - 1);
|
||
}
|
||
|
||
|
||
|
||
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
|
||
// Draw inactive curves dimmed only if there are curve sets
|
||
if (this.curveSets && Object.keys(this.curveSets).length > 0) {
|
||
for (const [motorID, curves] of Object.entries(this.curveSets)) {
|
||
if (!curves || curves.length === 0) continue; // skip empty sets
|
||
const isSelected = motorID == this.selectedMotorID;
|
||
this.drawCurves(this.ctx, curves, isSelected ? 1.0 : 0.3, isSelected);
|
||
}
|
||
}
|
||
|
||
|
||
this.drawCurves(ctx, this.curves);
|
||
|
||
this.drawMotorButtons();
|
||
|
||
|
||
this.drawDurationHandle(ctx);
|
||
}
|
||
|
||
drawDurationHandle() {
|
||
const ctx = this.ctx;
|
||
const x = this.valueToX(this.durationHandle.x); // e.g. derived from header.frameCount or maxTime
|
||
const y = 20; // top of canvas
|
||
ctx.save();
|
||
|
||
// vertical guide line
|
||
ctx.strokeStyle = "red";
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, 0);
|
||
ctx.lineTo(x, this.canvas.height);
|
||
ctx.stroke();
|
||
|
||
// draggable knob at the top
|
||
ctx.fillStyle = "red";
|
||
ctx.beginPath();
|
||
ctx.arc(x, this.durationHandle.y, this.durationHandle.radius, 0, Math.PI * 2); // circle 10px down from top
|
||
ctx.fill();
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
|
||
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;
|
||
if (!curves) {
|
||
return;
|
||
}
|
||
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;
|
||
|
||
// Check duration handle
|
||
const handleX = this.valueToX(this.durationHandle.x);
|
||
const handleY = this.durationHandle.y; // same as draw
|
||
if (Math.hypot(e.offsetX - handleX, e.offsetY - handleY) < this.durationHandle.radius) {
|
||
this.dragging = { type: "durationHandle" };
|
||
return;
|
||
}
|
||
|
||
|
||
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 ['startPointHandle', 'endPointHandle', 'startPoint', '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 if (this.dragging.type === "durationHandle") {
|
||
// Update duration in timeline units
|
||
const newTime = Math.max(0, mouse.x); // timeline units
|
||
|
||
|
||
// Optionally update timeline length
|
||
this.setLength(newTime);
|
||
this.adjustAllCurvesToDuration(newTime); // truncate/extend curves
|
||
|
||
//console.log(this.durationHandle.x);
|
||
this.draw();
|
||
return;
|
||
|
||
} 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;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
adjustAllCurvesToDuration(newTime) {
|
||
for (const [motorID, curves] of Object.entries(this.curveSets)) {
|
||
if (!curves || curves.length === 0) continue;
|
||
|
||
for (let i = 0; i < curves.length; i++) {
|
||
const curve = curves[i];
|
||
|
||
// If a curve starts beyond the new duration, drop it
|
||
if (curve.startPoint.x >= newTime) {
|
||
curves.splice(i);
|
||
break;
|
||
}
|
||
|
||
// If a curve ends beyond the new duration, truncate it
|
||
if (curve.endPoint.x > newTime) {
|
||
curve.endPoint.x = newTime;
|
||
curve.endPointHandle.x = newTime;
|
||
curves.splice(i + 1);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// If extending, push the last curve’s end point out
|
||
const last = curves[curves.length - 1];
|
||
if (last.endPoint.x < newTime) {
|
||
const delta = newTime - last.endPoint.x;
|
||
last.endPoint.x = newTime;
|
||
last.endPointHandle.x += delta;
|
||
}
|
||
|
||
this.curveSets[motorID] = curves;
|
||
}
|
||
|
||
// Update the active reference too
|
||
this.curves = this.curveSets[this.selectedMotorID] || [];
|
||
}
|
||
|
||
|
||
|
||
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
|
||
);
|
||
}
|
||
|
||
}
|