From 97b9ff71b725a607cd59dac5d5e6b165d621aede Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 19 Oct 2025 18:45:17 -0400 Subject: [PATCH] Fix curve issues --- src/main.js | 20 +++- src/widgets.js | 303 ++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 267 insertions(+), 56 deletions(-) diff --git a/src/main.js b/src/main.js index 14f72ad..eed424d 100644 --- a/src/main.js +++ b/src/main.js @@ -4783,8 +4783,8 @@ class GraphicsObject extends Widget { } // Find surrounding keyframes using AnimationCurve's built-in method - const { prev: prevKf, next: nextKf } = shapeIndexCurve.getBracketingKeyframes(currentTime); - console.log(`[Widget.draw] Keyframes: prevKf=${JSON.stringify(prevKf)}, nextKf=${JSON.stringify(nextKf)}`); + const { prev: prevKf, next: nextKf, t: interpolationT } = shapeIndexCurve.getBracketingKeyframes(currentTime); + console.log(`[Widget.draw] Keyframes: prevKf=${JSON.stringify(prevKf)}, nextKf=${JSON.stringify(nextKf)}, t=${interpolationT}`); // Get interpolated value let shapeIndexValue = shapeIndexCurve.interpolate(currentTime); @@ -4817,8 +4817,10 @@ class GraphicsObject extends Widget { const shape2 = shapes.find(s => s.shapeIndex === nextKf.value); if (shape1 && shape2) { - // Calculate t based on time position between keyframes - const t = (currentTime - prevKf.time) / (nextKf.time - prevKf.time); + // Use the interpolated shapeIndexValue to calculate blend factor + // This respects the bezier easing curve + const t = (shapeIndexValue - prevKf.value) / (nextKf.value - prevKf.value); + console.log(`[Widget.draw] Morphing from shape ${prevKf.value} to ${nextKf.value}, shapeIndexValue=${shapeIndexValue}, t=${t}`); const morphedShape = shape1.lerpShape(shape2, t); visibleShapes.push({ shape: morphedShape, @@ -6285,7 +6287,15 @@ function addKeyframeAtPlayhead() { } const shapeIndexCurve = animationData.getOrCreateCurve(`shape.${obj.shapeId}.shapeIndex`); - const shapeIndexKeyframe = new Keyframe(currentTime, newShapeIndex, 'linear'); + // Check if a keyframe already exists at this time to preserve its interpolation type + const framerate = context.config?.framerate || 24; + const timeResolution = (1 / framerate) / 2; + const existingShapeIndexKf = shapeIndexCurve.getKeyframeAtTime(currentTime, timeResolution); + const interpolationType = existingShapeIndexKf ? existingShapeIndexKf.interpolation : 'linear'; + const shapeIndexKeyframe = new Keyframe(currentTime, newShapeIndex, interpolationType); + // Preserve easeIn/easeOut if they exist + if (existingShapeIndexKf && existingShapeIndexKf.easeIn) shapeIndexKeyframe.easeIn = existingShapeIndexKf.easeIn; + if (existingShapeIndexKf && existingShapeIndexKf.easeOut) shapeIndexKeyframe.easeOut = existingShapeIndexKf.easeOut; shapeIndexCurve.addKeyframe(shapeIndexKeyframe); console.log(`Created new shape version with shapeIndex ${newShapeIndex} at time ${currentTime}`); diff --git a/src/widgets.js b/src/widgets.js index eb9857f..8d16439 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -555,6 +555,9 @@ class TimelineWindowV2 extends Widget { // Hover state for showing keyframe values this.hoveredKeyframe = null // {keyframe, x, y} - keyframe being hovered over and its screen position + // Hidden curves (Phase 6) - Set of curve parameter names + this.hiddenCurves = new Set() + // Phase 6: Segment dragging state this.draggingSegment = null // {track, initialMouseTime, segmentStartTime, animationData} @@ -764,6 +767,83 @@ class TimelineWindowV2 extends Widget { ctx.fillStyle = foregroundColor ctx.fillRect(buttonX + 2, buttonY + 2, buttonSize - 4, buttonSize - 4) } + + // Draw legend for expanded curves (Phase 6) + if (track.object.curvesMode === 'expanded') { + // Get curves for this track + const curves = [] + const obj = track.object + let animationData = null + + // Find the AnimationData for this track + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.shapes && layer.shapes.some(s => s.shapeId === obj.shapeId)) { + animationData = layer.animationData + break + } + } + } + + if (animationData) { + const prefix = track.type === 'object' ? `child.${obj.idx}.` : `shape.${obj.shapeId}.` + for (let curveName in animationData.curves) { + if (curveName.startsWith(prefix)) { + curves.push(animationData.curves[curveName]) + } + } + } + + if (curves.length > 0) { + ctx.save() + const legendPadding = 3 + const legendLineHeight = 12 + const legendHeight = curves.length * legendLineHeight + legendPadding * 2 + const legendY = y + this.trackHierarchy.trackHeight + 5 // Below track name row + + // Draw legend items (no background box) + ctx.font = '9px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'top' + + for (let i = 0; i < curves.length; i++) { + const curve = curves[i] + const itemY = legendY + legendPadding + i * legendLineHeight + const isHidden = this.hiddenCurves.has(curve.parameter) + + // Draw color dot (grayed out if hidden) + ctx.fillStyle = isHidden ? foregroundColor : curve.displayColor + ctx.beginPath() + ctx.arc(10, itemY + 5, 3, 0, 2 * Math.PI) + ctx.fill() + + // Draw parameter name (extract last part after last dot) + ctx.fillStyle = isHidden ? foregroundColor : labelColor + const paramName = curve.parameter.split('.').pop() + const truncatedName = paramName.length > 12 ? paramName.substring(0, 10) + '...' : paramName + ctx.fillText(truncatedName, 18, itemY) + + // Draw strikethrough if hidden + if (isHidden) { + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.beginPath() + const textWidth = ctx.measureText(truncatedName).width + ctx.moveTo(18, itemY + 5) + ctx.lineTo(18 + textWidth, itemY + 5) + ctx.stroke() + } + } + ctx.restore() + } + } } } @@ -1202,48 +1282,10 @@ class TimelineWindowV2 extends Widget { return startY + curveHeight - padding - (normalizedValue * (curveHeight - 2 * padding)) } - // Draw legend showing which color is which parameter - // Position it below the track name area, top-right of the curve area - ctx.save() - ctx.fillStyle = backgroundColor - ctx.strokeStyle = shadow - ctx.lineWidth = 1 - - // Calculate legend size - const legendPadding = 4 - const legendLineHeight = 14 - const legendHeight = curves.length * legendLineHeight + legendPadding * 2 - const legendWidth = 100 - const legendX = 5 // Small left margin - const legendY = startY + 40 // Below track name area - - // Draw legend background - ctx.fillRect(legendX, legendY, legendWidth, legendHeight) - ctx.strokeRect(legendX, legendY, legendWidth, legendHeight) - - // Draw legend items - ctx.font = '10px sans-serif' - ctx.textBaseline = 'top' - for (let i = 0; i < curves.length; i++) { - const curve = curves[i] - const y = legendY + legendPadding + i * legendLineHeight - - // Draw color dot - ctx.fillStyle = curve.displayColor - ctx.beginPath() - ctx.arc(legendX + legendPadding + 4, y + 6, 3, 0, 2 * Math.PI) - ctx.fill() - - // Draw parameter name (extract last part after last dot) - ctx.fillStyle = labelColor - const paramName = curve.parameter.split('.').pop() - ctx.fillText(paramName, legendX + legendPadding + 12, y + 2) - } - ctx.restore() - // Draw each curve for (let curve of curves) { if (curve.keyframes.length === 0) continue + if (this.hiddenCurves.has(curve.parameter)) continue // Skip hidden curves ctx.strokeStyle = curve.displayColor ctx.fillStyle = curve.displayColor @@ -1556,6 +1598,65 @@ class TimelineWindowV2 extends Widget { if (this.requestRedraw) this.requestRedraw() return true } + + // Check if clicking on legend items (Phase 6) + if (track.object.curvesMode === 'expanded') { + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackYPos = this.trackHierarchy.getTrackY(trackIndex) + const legendPadding = 3 + const legendLineHeight = 12 + const legendY = trackYPos + this.trackHierarchy.trackHeight + 5 + + // Get curves for this track + const curves = [] + const obj = track.object + let animationData = null + + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.shapes && layer.shapes.some(s => s.shapeId === obj.shapeId)) { + animationData = layer.animationData + break + } + } + } + + if (animationData) { + const prefix = track.type === 'object' ? `child.${obj.idx}.` : `shape.${obj.shapeId}.` + for (let curveName in animationData.curves) { + if (curveName.startsWith(prefix)) { + curves.push(animationData.curves[curveName]) + } + } + } + + // Check if clicking on any legend item + for (let i = 0; i < curves.length; i++) { + const curve = curves[i] + const itemY = legendY + legendPadding + i * legendLineHeight + + // Legend items are from x=5 to x=145, height of 12px + if (x >= 5 && x <= 145 && adjustedY >= itemY && adjustedY <= itemY + legendLineHeight) { + // Toggle visibility of this curve + if (this.hiddenCurves.has(curve.parameter)) { + this.hiddenCurves.delete(curve.parameter) + console.log(`Showing curve: ${curve.parameter}`) + } else { + this.hiddenCurves.add(curve.parameter) + console.log(`Hiding curve: ${curve.parameter}`) + } + if (this.requestRedraw) this.requestRedraw() + return true + } + } + } } // Clicking elsewhere on track header selects it @@ -1575,6 +1676,7 @@ class TimelineWindowV2 extends Widget { // Phase 6: Check if clicking on tangent handle (highest priority for curves) if ((track.type === 'object' || track.type === 'shape') && track.object.curvesMode === 'expanded') { const tangentInfo = this.getTangentHandleAtPoint(track, adjustedX, adjustedY) + console.log(`Tangent handle check result:`, tangentInfo) if (tangentInfo) { // Start tangent dragging this.draggingTangent = { @@ -1756,6 +1858,9 @@ class TimelineWindowV2 extends Widget { // Check if clicking close to an existing keyframe on ANY curve (within 8px) // First pass: check all curves for keyframe hits for (let curve of curves) { + // Skip hidden curves + if (this.hiddenCurves.has(curve.parameter)) continue + for (let keyframe of curve.keyframes) { const kfX = this.timelineState.timeToPixel(keyframe.time) const kfY = startY + curveHeight - padding - ((keyframe.value - minValue) / (maxValue - minValue) * (curveHeight - 2 * padding)) @@ -1786,6 +1891,12 @@ class TimelineWindowV2 extends Widget { console.log(`Selected single keyframe`) } + // Don't start dragging if this was a right-click + if (this.lastClickEvent?.button === 2) { + console.log(`Skipping drag - right-click detected (button=${this.lastClickEvent.button})`) + return true + } + // Start dragging this keyframe (and all selected keyframes) this.draggingKeyframe = { curve: curve, // Use the actual curve we clicked on @@ -1813,11 +1924,14 @@ class TimelineWindowV2 extends Widget { } // No keyframe was clicked, so add a new one - // Find the closest curve to the click position - let targetCurve = curves[0] + // Find the closest curve to the click position (only visible curves) + let targetCurve = null let minDistance = Infinity for (let curve of curves) { + // Skip hidden curves + if (this.hiddenCurves.has(curve.parameter)) continue + // For each curve, find the value at this time const curveValue = curve.interpolate(clickTime) if (curveValue !== null) { @@ -1831,6 +1945,9 @@ class TimelineWindowV2 extends Widget { } } + // If all curves are hidden, don't add a keyframe + if (!targetCurve) return false + console.log('Adding keyframe at time', clickTime, 'with value', clickValue, 'to curve', targetCurve.parameter) // Create keyframe directly @@ -2252,6 +2369,9 @@ class TimelineWindowV2 extends Widget { // Check each curve for tangent handles for (let curve of curves) { + // Skip hidden curves + if (this.hiddenCurves.has(curve.parameter)) continue + // Only check bezier keyframes that are selected for (let i = 0; i < curve.keyframes.length; i++) { const kf = curve.keyframes[i] @@ -2552,7 +2672,58 @@ class TimelineWindowV2 extends Widget { // Apply the delta selectedKeyframe.time = Math.max(0, selectedKeyframe.initialDragTime + timeDelta) - selectedKeyframe.value = selectedKeyframe.initialDragValue + valueDelta + let newValue = selectedKeyframe.initialDragValue + valueDelta + + // Special validation for shapeIndex curves: only allow values that correspond to actual shapes + if (this.draggingKeyframe.curve.parameter.endsWith('.shapeIndex')) { + // Extract shapeId from parameter name: "shape.{shapeId}.shapeIndex" + const match = this.draggingKeyframe.curve.parameter.match(/^shape\.([^.]+)\.shapeIndex$/) + if (match) { + const shapeId = match[1] + + // Find all shapes with this shapeId and get their shapeIndex values + const track = this.draggingKeyframe.track + let layer = null + + if (track.type === 'shape') { + // Find the layer containing this shape + for (let l of this.context.activeObject.allLayers) { + if (l.shapes && l.shapes.some(s => s.shapeId === shapeId)) { + layer = l + break + } + } + } + + if (layer) { + const validIndexes = layer.shapes + .filter(s => s.shapeId === shapeId) + .map(s => s.shapeIndex) + .sort((a, b) => a - b) + + if (validIndexes.length > 0) { + // Round to nearest integer first + const roundedValue = Math.round(newValue) + + // Find the closest valid index + let closestIndex = validIndexes[0] + let closestDist = Math.abs(roundedValue - closestIndex) + + for (let validIndex of validIndexes) { + const dist = Math.abs(roundedValue - validIndex) + if (dist < closestDist) { + closestDist = dist + closestIndex = validIndex + } + } + + newValue = closestIndex + } + } + } + } + + selectedKeyframe.value = newValue } // Resort keyframes in all affected curves @@ -3045,18 +3216,37 @@ class TimelineWindowV2 extends Widget { maxValue += rangePadding // Check if right-clicking on a keyframe (within 8px) + // Find the CLOSEST keyframe, not just the first one (and skip hidden curves) + let closestKeyframe = null + let closestCurve = null + let closestDistance = 8 // Maximum hit distance + for (let curve of curves) { + // Skip hidden curves + if (this.hiddenCurves.has(curve.parameter)) continue + for (let i = 0; i < curve.keyframes.length; i++) { const keyframe = curve.keyframes[i] const kfX = this.timelineState.timeToPixel(keyframe.time) const kfY = startY + curveHeight - padding - ((keyframe.value - minValue) / (maxValue - minValue) * (curveHeight - 2 * padding)) const distance = Math.sqrt((adjustedX - kfX) ** 2 + (adjustedY - kfY) ** 2) - if (distance < 8) { - // Phase 6: Check if shift key is pressed for quick delete - const shiftPressed = event && event.shiftKey + if (distance < closestDistance) { + closestDistance = distance + closestKeyframe = keyframe + closestCurve = curve + } + } + } - if (shiftPressed) { + if (closestKeyframe) { + const keyframe = closestKeyframe + const curve = closestCurve + + // Phase 6: Check if shift key is pressed for quick delete + const shiftPressed = event && event.shiftKey + + if (shiftPressed) { // Shift+right-click: quick delete if (this.selectedKeyframes.size > 1) { // Delete all selected keyframes @@ -3084,15 +3274,24 @@ class TimelineWindowV2 extends Widget { } else { // Regular right-click: show context menu if (this.selectedKeyframes.size > 1) { - this.showKeyframeContextMenu(Array.from(this.selectedKeyframes), curves) + // If right-clicking on a selected keyframe, show menu for all selected + if (this.selectedKeyframes.has(keyframe)) { + this.showKeyframeContextMenu(Array.from(this.selectedKeyframes), curves) + } else { + // Right-clicking on unselected keyframe: select it and show menu + this.selectedKeyframes.clear() + this.selectedKeyframes.add(keyframe) + this.showKeyframeContextMenu([keyframe], curves, curve) + } } else { + // No multi-selection: select this keyframe and show menu + this.selectedKeyframes.clear() + this.selectedKeyframes.add(keyframe) this.showKeyframeContextMenu([keyframe], curves, curve) } return true } - } - } - } + } // end if (closestKeyframe) } } } @@ -3124,6 +3323,7 @@ class TimelineWindowV2 extends Widget { action: async () => { keyframe.interpolation = 'linear' console.log('Changed interpolation to linear') + // Keep flag set until next mousedown processes it if (this.context.updateUI) this.context.updateUI() if (this.requestRedraw) this.requestRedraw() } @@ -3134,7 +3334,6 @@ class TimelineWindowV2 extends Widget { keyframe.interpolation = 'bezier' if (!keyframe.easeIn) keyframe.easeIn = { x: 0.42, y: 0 } if (!keyframe.easeOut) keyframe.easeOut = { x: 0.58, y: 1 } - console.log('Changed interpolation to bezier') if (this.context.updateUI) this.context.updateUI() if (this.requestRedraw) this.requestRedraw() } @@ -3144,6 +3343,7 @@ class TimelineWindowV2 extends Widget { action: async () => { keyframe.interpolation = 'step' console.log('Changed interpolation to step') + // Keep flag set until next mousedown processes it if (this.context.updateUI) this.context.updateUI() if (this.requestRedraw) this.requestRedraw() } @@ -3153,6 +3353,7 @@ class TimelineWindowV2 extends Widget { action: async () => { keyframe.interpolation = 'zero' console.log('Changed interpolation to zero') + // Keep flag set until next mousedown processes it if (this.context.updateUI) this.context.updateUI() if (this.requestRedraw) this.requestRedraw() }