keyframe saving works, save/load broken

master
Jake Wilkinson 2025-11-24 00:16:25 +08:00
parent f8ec407a45
commit 5a8dbc28a7
5 changed files with 282 additions and 98 deletions

View File

@ -117,19 +117,22 @@ export class CurveEditor {
} }
addChannel(motorID) { addChannel(motorID) {
const midY = this.logicalHeight / 2;//this.offset.y + (this.logicalHeight / 2) * this.scaleY;
this.setCurves([ this.setCurves([
{ {
startPoint: { x: 0, y: this.canvas.height / 2 }, startPoint: { x: 0, y: midY },
startPointHandle: { x: this.timelineLength * this.pixelsPerSecond * 0.25, y: this.canvas.height / 2 }, startPointHandle: { x: this.timelineLength * this.pixelsPerSecond * 0.25, y: midY },
endPointHandle: { x: this.timelineLength * this.pixelsPerSecond * 0.75, y: this.canvas.height / 2 }, endPointHandle: { x: this.timelineLength * this.pixelsPerSecond * 0.75, y: midY },
endPoint: { x: this.timelineLength * this.pixelsPerSecond, y: this.canvas.height / 2 } endPoint: { x: this.timelineLength * this.pixelsPerSecond, y: midY }
} }
]); ]);
//console.log("TL LENGTH: " + this.timelineLength);
this.curveSets[motorID] = this.curves; this.curveSets[motorID] = this.curves;
} }
setLength(endTime) { setLength(endTime) {
this.timelineLength = endTime / this.pixelsPerSecond; this.timelineLength = endTime / this.pixelsPerSecond;
this.slider.max = this.timelineLength * this.pixelsPerSecond; this.slider.max = this.timelineLength * this.pixelsPerSecond;
@ -143,7 +146,7 @@ export class CurveEditor {
this.draw(); this.draw();
} }
getLength(){ getLength() {
return this.timelineLength * this.pixelsPerSecond; return this.timelineLength * this.pixelsPerSecond;
} }
@ -190,6 +193,11 @@ export class CurveEditor {
return (this.canvas.height / 2 - v * (this.canvas.height / 2)); return (this.canvas.height / 2 - v * (this.canvas.height / 2));
} }
yToValueNoOffset(y) {
return 1 - (2 * y / this.canvas.height);
}
valueToY(v) { valueToY(v) {
return (this.logicalHeight / 2 - v * (this.logicalHeight / 2)) * this.scaleY + this.offset.y; return (this.logicalHeight / 2 - v * (this.logicalHeight / 2)) * this.scaleY + this.offset.y;
} }
@ -200,21 +208,46 @@ export class CurveEditor {
// Maps normalised -1 to 1 value to motor range (0, 4095) // Maps normalised -1 to 1 value to motor range (0, 4095)
yToExportRange(y) { yToExportRange(yCanvas) {
const [minOut, maxOut] = this.exportRange; 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] // undo offset + scale to get logical Y
return Math.round(normalized * (maxOut - minOut) + minOut); // maps to [minOut, maxOut] const yLogical = (yCanvas - this.offset.y) / this.scaleY;
// invert valueToYNoOffset: get back logical [-1,1]
const logical = this.yToValueNoOffset(yLogical);
// map logical [-1,1] → normalized [0,1]
const normalized = (logical + 1) / 2;
// flip back (since exportRangeToY used 1 - normalized)
const flipped = 1 - normalized;
// map to export range
return (flipped * (maxOut - minOut) + minOut)/2;
} }
exportRangeToY(value) { exportRangeToY(value) {
const [minOut, maxOut] = this.exportRange; const [minOut, maxOut] = this.exportRange;
const normalized = 1 - (value - minOut) / (maxOut - minOut); // flipped
return this.valueToYNoOffset(normalized * 2 - 1); // normalize value into [0,1], flipped
const normalized = 1 - (value - minOut) / (maxOut - minOut);
// map into logical [-1,1]
const logical = normalized * 2 - 1;
// convert logical → logical Y
const yLogical = this.valueToYNoOffset(logical);
// apply scale + offset to get canvas Y
return yLogical * this.scaleY + this.offset.y;
} }
valueToX(value) { valueToX(value) {
return value * this.pixelsPerSecond * this.scaleX + this.offset.x; return value * this.pixelsPerSecond * this.scaleX + this.offset.x;
} }
@ -355,16 +388,13 @@ export class CurveEditor {
getMotorPositionAtTime(motorID, timeInFrames) { getMotorPositionAtTime(motorID, timeInFrames) {
if (this.curveSets[motorID] === undefined || this.curveSets[motorID].length === 0) {
console.log("THIS");
return null;
}
const curves = this.curveSets[motorID]; const curves = this.curveSets[motorID];
const timeInSeconds = timeInFrames / this.framesPerSecond; if (!curves || curves.length === 0) return null;
const currentTimeX = timeInFrames;
// put time into canvas space if youre transforming points
const currentTimeX = this.offset.x + timeInFrames * this.scaleX;
for (let curve of curves) { for (let curve of curves) {
const p0 = this.transform(curve.startPoint); const p0 = this.transform(curve.startPoint);
const h0 = this.transform(curve.startPointHandle); const h0 = this.transform(curve.startPointHandle);
const h1 = this.transform(curve.endPointHandle); const h1 = this.transform(curve.endPointHandle);
@ -372,18 +402,20 @@ export class CurveEditor {
const minX = Math.min(p0.x, p1.x); const minX = Math.min(p0.x, p1.x);
const maxX = Math.max(p0.x, p1.x); const maxX = Math.max(p0.x, p1.x);
if (currentTimeX >= minX && currentTimeX <= maxX) { if (currentTimeX >= minX && currentTimeX <= maxX) {
const t = this.solveTForX(currentTimeX, p0, h0, h1, p1); const t = this.solveTForX(currentTimeX, p0, h0, h1, p1);
const pt = this.cubicBezier(t, p0, h0, h1, p1); const pt = this.cubicBezier(t, p0, h0, h1, p1);
return this.yToExportRange(pt.y); return this.yToExportRange(pt.y); // or pt.y if you want raw canvas Y
} }
} }
return null; // No curve segment matched the time return null;
} }
drawCurves(ctx, curves, opacity = 1.0, showControlPoints = true) { drawCurves(ctx, curves, opacity = 1.0, showControlPoints = true) {
ctx.save(); ctx.save();
ctx.globalAlpha = opacity; ctx.globalAlpha = opacity;
@ -706,62 +738,101 @@ export class CurveEditor {
this.canvas.addEventListener('dblclick', e => { this.canvas.addEventListener('dblclick', e => {
const mouse = this.inverseTransform({ x: e.offsetX, y: e.offsetY }); const mouse = this.inverseTransform({ x: e.offsetX, y: e.offsetY });
//const xPosInTicks = parseInt(this.xToValue(mouse.x)*this.pixelsPerSecond);
for (let i = 0; i < this.curves.length; i++) { //console.log(mouse.x);
const curve = this.curves[i]; this.splitCurveAtTime(this.selectedMotorID, mouse.x);
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;
}
}
}); });
} }
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];
// --- merge with previous if near startPoint ---
if (i > 0 && Math.abs(curve.startPoint.x - timeX) < 1e-3) {
const prev = curves[i - 1];
const merged = {
startPoint: prev.startPoint,
startPointHandle: prev.startPointHandle,
endPointHandle: curve.endPointHandle,
endPoint: curve.endPoint
};
curves.splice(i - 1, 2, merged);
this.curveSets[motorID] = curves;
this.draw();
return;
}
// --- merge with next if near endPoint ---
if (i < curves.length - 1 && Math.abs(curve.endPoint.x - timeX) < 1e-3) {
const next = curves[i + 1];
const merged = {
startPoint: curve.startPoint,
startPointHandle: curve.startPointHandle,
endPointHandle: next.endPointHandle,
endPoint: next.endPoint
};
curves.splice(i, 2, merged);
this.curveSets[motorID] = curves;
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
// adjust the shared split point
left.endPoint.y = yEditor;
right.startPoint.y = yEditor;
// ✅ flatten handles to same Y
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) { adjustAllCurvesToDuration(newTime) {
for (const [motorID, curves] of Object.entries(this.curveSets)) { for (const [motorID, curves] of Object.entries(this.curveSets)) {
if (!curves || curves.length === 0) continue; if (!curves || curves.length === 0) continue;

View File

@ -46,6 +46,7 @@ export class URDFEditor {
this.hoveredJoint = null; this.hoveredJoint = null;
this.draggedJoint = null; this.draggedJoint = null;
this.selectedJoint = null;
this.worldAxis = null; this.worldAxis = null;
this.lastX = null; this.lastX = null;
this.isDragging = false; this.isDragging = false;
@ -390,22 +391,34 @@ export class URDFEditor {
// Hover highlighting
// Hover highlighting // Hover highlighting
if (intersects.length > 0) { if (intersects.length > 0) {
const target = intersects[0].object; const target = intersects[0].object;
if (this.hoveredJoint?.material?.emissive) {
this.hoveredJoint.material.emissive.setHex(0x000000); // reset previous hover if it's not selected
if (this.hoveredJoint && this.hoveredJoint !== this.selectedJoint) {
if (this.hoveredJoint?.material?.emissive) {
this.hoveredJoint.material.emissive.setHex(0x000000);
}
} }
this.hoveredJoint = target; this.hoveredJoint = target;
if (this.hoveredJoint.material?.emissive) { if (this.hoveredJoint.material?.emissive) {
this.hoveredJoint.material.emissive.setHex(0x333333); // use a different color if it's also selected
const color = (this.hoveredJoint === this.selectedJoint) ? 0x555555 : 0x333333;
this.hoveredJoint.material.emissive.setHex(color);
} }
} else { } else {
if (this.hoveredJoint?.material?.emissive) { if (this.hoveredJoint && this.hoveredJoint !== this.selectedJoint) {
this.hoveredJoint.material.emissive.setHex(0x000000); if (this.hoveredJoint?.material?.emissive) {
this.hoveredJoint.material.emissive.setHex(0x000000);
}
} }
this.hoveredJoint = null; this.hoveredJoint = null;
} }
} }
setSyncMode(newMode) { setSyncMode(newMode) {
@ -419,6 +432,61 @@ export class URDFEditor {
} }
} }
findMotorByID(motorID) {
for (const jointName in this.robot.joints) {
const joint = this.robot.joints[jointName];
const transmission = joint.transmission;
if (!transmission) continue;
if (transmission.motorID === motorID) {
return transmission;
}
}
}
findAllMotorIDs() {
let list = [];
for (const jointName in this.robot.joints) {
const joint = this.robot.joints[jointName];
const transmission = joint.transmission;
if (!transmission) continue;
list.push(transmission.motorID);
}
return list;
}
getMotorTicks(motorID) {
if (!this.robot?.joints) return null;
for (const [jointName, jointData] of Object.entries(this.robot.joints)) {
const t = jointData.transmission;
if (!t) continue;
//console.log(t);
if (t.motorID === motorID) {
const angle = this.jointAngles?.[jointName] ?? 0;
return parseInt(radiansToTicks(angle, t));
}
}
return null; // not found
}
syncWithCurveEditor() {
let allIDs = this.findAllMotorIDs();
let ids = [];
let positions = [];
allIDs.forEach(id => {
let pos = this.curveEditor.getMotorPositionAtTime(id, this.curveEditor.currentTime);
this.setMotorPosition(id, pos);
ids.push(id);
positions.push(pos);
});
if (this.currentSyncMode === SyncMode.SimToReal) {
this.sendMotorPosition(ids, positions);
}
}
setMotorPosition(motorID, positionTicks) { setMotorPosition(motorID, positionTicks) {
for (const jointName in this.robot.joints) { for (const jointName in this.robot.joints) {
@ -459,11 +527,6 @@ export class URDFEditor {
} }
} }
onRotateMotor(motorID, positionTicks) { onRotateMotor(motorID, positionTicks) {
if (this.currentSyncMode === SyncMode.SimToReal) { if (this.currentSyncMode === SyncMode.SimToReal) {
this.sendMotorPosition(motorID, positionTicks); this.sendMotorPosition(motorID, positionTicks);
@ -473,6 +536,15 @@ export class URDFEditor {
onPointerDown(event) { onPointerDown(event) {
if (!this.hoveredJoint) return; if (!this.hoveredJoint) return;
if (this.hoveredJoint) {
// reset previous selection
if (this.selectedJoint) {
this.selectedJoint.material.emissive.setHex(0x000000);
}
this.selectedJoint = this.hoveredJoint;
this.selectedJoint.material.emissive.setHex(0x555555); // selection color
}
this.isDragging = true; this.isDragging = true;
this.lastX = event.clientX; this.lastX = event.clientX;
this.controls.enabled = false; this.controls.enabled = false;

View File

@ -165,12 +165,30 @@ export class ViewerOverlay {
if (slider.max != this.parent.curveEditor.getLength()) { if (slider.max != this.parent.curveEditor.getLength()) {
slider.max = this.parent.curveEditor.getLength(); slider.max = this.parent.curveEditor.getLength();
} }
this.parent.syncWithCurveEditor();
} }
); );
panel.addElement(slider); panel.addElement(slider);
panel.addElement(new Button(panel.x + 200, panel.y + 40, 200, 24, "Record Keyframe ALL", () => {
let allIDs = this.parent.findAllMotorIDs();
for (var i = 0; i < allIDs.length; i++){
this.parent.curveEditor.splitCurveAtTime(allIDs[i], this.parent.curveEditor.currentTime, this.parent.getMotorTicks(allIDs[i]));
}
}));
panel.addElement(new Button(panel.x + 200, panel.y + 40 + 24*2, 200, 24, "Record Keyframe Selected", () => {
if (!this.parent.selectedJoint){
return;
}
const selectedID = this.parent.findJointAncestor(this.parent.selectedJoint, 0).transmission.motorID
const currentTime = this.parent.curveEditor.currentTime;
const motorPosition = this.parent.getMotorTicks(selectedID)
this.parent.curveEditor.splitCurveAtTime(selectedID, currentTime, motorPosition);
console.log(motorPosition, this.parent.curveEditor.exportRangeToY(motorPosition), this.parent.curveEditor.yToExportRange(this.parent.curveEditor.exportRangeToY(motorPosition)));
//console.log(selectedID, currentTime, motorPosition);
}));
return panel; return panel;
} }

View File

@ -80,7 +80,7 @@ class UIElement {
if (this.tooltip && this.hovered && this.hoverStart) { if (this.tooltip && this.hovered && this.hoverStart) {
const elapsed = performance.now() - this.hoverStart; const elapsed = performance.now() - this.hoverStart;
if (elapsed >= this.tooltipDelay) { if (elapsed >= this.tooltipDelay) {
this.tooltip.show(this.x + 100, this.y + this.h*2); this.tooltip.show(this.x + 100, this.y + this.h * 2);
this.tooltip.draw(ctx); this.tooltip.draw(ctx);
} else { } else {
this.tooltip.hide(); this.tooltip.hide();
@ -384,7 +384,8 @@ export class Slider extends UIElement {
// knob // knob
ctx.fillStyle = this.hovered || this.dragging ? '#0f0' : '#fff'; ctx.fillStyle = this.hovered || this.dragging ? '#0f0' : '#fff';
ctx.beginPath(); ctx.beginPath();
ctx.arc(knobX, knobY, this.h / 2, 0, Math.PI * 2); const size = this.h; // or this.h/2 if you want it smaller
ctx.rect(knobX - size / 2, knobY - size / 2, size/2, size);
ctx.fill(); ctx.fill();
ctx.strokeStyle = '#333'; ctx.strokeStyle = '#333';
ctx.stroke(); ctx.stroke();

View File

@ -226,25 +226,47 @@ window.onload = () => {
}; };
function sendMotorPosition(motorID, position) { function sendMotorPosition(motorID, position) {
const now = Date.now(); const now = Date.now();
if (now - lastSyncTime >= syncIntervalMs) { if (now - lastSyncTime < syncIntervalMs) return;
lastSyncTime = now; lastSyncTime = now;
// Each payload is 3 bytes: [motorId (uint8), position (uint16 little-endian)]
// Case 1: arrays → multiple motors
if (Array.isArray(motorID) && Array.isArray(position)) {
if (motorID.length !== position.length) {
console.error("motorID and position arrays must be same length");
return;
}
const buffer = new ArrayBuffer(motorID.length * 3);
const view = new DataView(buffer);
motorID.forEach((id, i) => {
const pos = position[i];
const offset = i * 3;
view.setUint8(offset, id);
view.setUint16(offset + 1, pos, true); // little-endian
});
const payload = new Uint8Array(buffer);
serial.sendSetPositions(payload);
//console.log("Multi-motor payload:", payload);
}
// Case 2: single motor
else {
const buffer = new ArrayBuffer(3); const buffer = new ArrayBuffer(3);
const view = new DataView(buffer); const view = new DataView(buffer);
view.setUint8(0, motorID); view.setUint8(0, motorID);
view.setUint16(1, position, true); // little-endian view.setUint16(1, position, true);
const payload = new Uint8Array(buffer); const payload = new Uint8Array(buffer);
// Send upward to parent / serial
serial.sendSetPositions(payload); serial.sendSetPositions(payload);
//console.log(payload); //console.log("Single-motor payload:", payload);
} }
} }
function syncMotorsWithTimeline() { function syncMotorsWithTimeline() {
const now = Date.now(); const now = Date.now();