sophia_controller/curveEditor.js

973 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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.scaleX = 1.66;
this.scaleY = 0.41;
this.offset.x = 177;
this.offset.y = 38;
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) {
const midY = this.logicalHeight / 2;//this.offset.y + (this.logicalHeight / 2) * this.scaleY;
this.setCurves([
{
startPoint: { x: 0, y: midY },
startPointHandle: { x: this.timelineLength * this.pixelsPerSecond * 0.25, y: midY },
endPointHandle: { x: this.timelineLength * this.pixelsPerSecond * 0.75, y: midY },
endPoint: { x: this.timelineLength * this.pixelsPerSecond, y: midY }
}
]);
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.logicalHeight / 2) * (1 - v); // Keep mapping logic as is
}
yToValueNoOffset(y) {
return 1 - (2 * y / this.canvas.height);
}
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));
}
//
yToMotorPosition(yCanvas) {
const [minOut, maxOut] = this.exportRange; // Output bounds (0 - 4095)
// Transform yCanvas to the original logical space by adjusting for the current offset and scale.
const yLogical = (yCanvas - this.offset.y) / this.scaleY;
// Normalize the logical value based on the actual height of the logical range
const normalized = yLogical / 800; // This assumes yLogical ranges from 0 to 800
// Flip normalized to convert to desired motor range
const flipped = normalized; // This gives the inverted mapping
// Calculate the final motor position
return Math.round(flipped * (maxOut - minOut) + minOut);
}
// exportRangeToY(value) {
// const [minOut, maxOut] = this.exportRange;
// const normalized = 1 - (value - minOut) / (maxOut - minOut);
// const logical = normalized * 2 - 1;
// const yLogical = this.valueToYNoOffset(logical);
// return yLogical * this.scaleY + this.offset.y;
// }
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;
// Inverted normalization
const normalized = 1 - (value - minOut) / (maxOut - minOut);
return this.valueToYNoOffset(normalized * 2 - 1); // maps [0, 1] to [-1, 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) {
const curves = this.curveSets[motorID];
if (!curves || curves.length === 0) return null;
// put time into canvas space if youre transforming points
const currentTimeX = this.offset.x + timeInFrames * this.scaleX;
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.yToMotorPosition(pt.y); // or pt.y if you want raw canvas Y
}
}
return null;
}
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;
console.log(this.scaleX, this.scaleY, this.offset.x, this.offset.y);
this.draw();
});
this.canvas.addEventListener('dblclick', e => {
const mouse = this.inverseTransform({ x: e.offsetX, y: e.offsetY });
//const xPosInTicks = parseInt(this.xToValue(mouse.x)*this.pixelsPerSecond);
//console.log(mouse.x);
this.splitCurveAtTime(this.selectedMotorID, mouse.x);
});
}
splitCurveAtTime(motorID, timeX, yPosition = null) {
const curves = this.curveSets[motorID];
if (!curves) return;
for (let i = 0; i < curves.length; i++) {
const curve = curves[i];
// --- if timeX matches an existing startPoint ---
if (Math.abs(curve.startPoint.x - timeX) < 1e-3) {
if (yPosition !== null) {
const yEditor = (yPosition * 800 / 4095);
curve.startPoint.y = yEditor;
if (curve.startPointHandle) curve.startPointHandle.y = yEditor;
}
this.draw();
return;
}
// --- if timeX matches an existing endPoint ---
if (Math.abs(curve.endPoint.x - timeX) < 1e-3) {
if (yPosition !== null) {
const yEditor = (yPosition * 800 / 4095);
curve.endPoint.y = yEditor;
if (curve.endPointHandle) curve.endPointHandle.y = yEditor;
}
this.draw();
return;
}
// --- split inside curve if timeX lies between start and end ---
const x0 = curve.startPoint.x;
const x3 = curve.endPoint.x;
if (timeX > x0 && timeX < x3) {
// binary search for t where curve.x ≈ timeX
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 < timeX) minT = t;
else maxT = t;
t = (minT + maxT) / 2;
}
let [left, right] = this.splitCurve(curve, t);
// ✅ If yPosition provided, override the split points Y
if (yPosition !== null) {
const yEditor = (yPosition * 800 / 4095);
left.endPoint.y = yEditor;
right.startPoint.y = yEditor;
if (left.endPointHandle) left.endPointHandle.y = yEditor;
if (right.startPointHandle) right.startPointHandle.y = yEditor;
}
curves.splice(i, 1, left, right);
this.curveSets[motorID] = curves;
this.draw();
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 curves 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
);
}
}