From 8dd907d63c9d7680654f414a1b00aec810359e62 Mon Sep 17 00:00:00 2001 From: JJ Date: Fri, 7 Nov 2025 13:50:15 +0000 Subject: [PATCH] power spline fix + anim --- web/power_spline_editor/graph_query.js | 16 -- web/power_spline_editor/handdraw_layer.js | 90 ++++++- web/power_spline_editor/layer_renderer.js | 245 +++++++++++++++++- .../power_spline_editor.js | 63 +++-- web/power_spline_editor/spline_canvas.js | 163 +++++++++--- web/power_spline_editor/spline_utils.js | 60 ++++- web/power_spline_editor/widget_utils.js | 31 ++- 7 files changed, 540 insertions(+), 128 deletions(-) diff --git a/web/power_spline_editor/graph_query.js b/web/power_spline_editor/graph_query.js index 6c946b1..9de26d2 100644 --- a/web/power_spline_editor/graph_query.js +++ b/web/power_spline_editor/graph_query.js @@ -250,13 +250,6 @@ async function extractImageFromSourceNode(sourceNodeObj) { } const sourceNode = sourceNodeObj.node; - console.log(`Attempting to extract image from source node:`, { - id: sourceNode.id, - type: sourceNode.type, - widgets: sourceNode.widgets ? sourceNode.widgets.map(w => ({name: w.name, type: w.type})) : [], - properties: sourceNode.properties - }); - try { // Different node types store image data differently switch (sourceNode.type) { @@ -403,15 +396,6 @@ async function extractImageFromSourceNode(sourceNodeObj) { } } - // Debug: log all available information about the source node - console.log(`Debug: No image found in source node ${sourceNode.type}. Available data:`, { - id: sourceNode.id, - type: sourceNode.type, - widgets: sourceNode.widgets?.map(w => ({name: w.name, type: w.type, value: typeof w.value === 'object' ? JSON.stringify(w.value) : w.value})), - properties: sourceNode.properties, - outputs: sourceNode.outputs - }); - return null; } catch (error) { console.error(`Error extracting image from source node ${sourceNode.type}:`, error); diff --git a/web/power_spline_editor/handdraw_layer.js b/web/power_spline_editor/handdraw_layer.js index a785e22..09f8cf0 100644 --- a/web/power_spline_editor/handdraw_layer.js +++ b/web/power_spline_editor/handdraw_layer.js @@ -13,7 +13,7 @@ export class HandDrawLayerWidget extends RgthreeBaseWidget { this.hitAreas = { toggle: { bounds: [0,0], onDown: this.onToggleDown }, name: { bounds: [0,0] }, - drawLabel: { bounds: [0,0] }, + drawLabel: { bounds: [0,0], onClick: this.onEditToggle }, repeatDec: { bounds: [0,0], onClick: this.onRepeatDec }, repeatVal: { bounds: [0,0], onClick: this.onRepeatClick }, repeatInc: { bounds: [0,0], onClick: this.onRepeatInc }, @@ -82,6 +82,11 @@ export class HandDrawLayerWidget extends RgthreeBaseWidget { this.hitAreas.toggle.bounds = drawTogglePart(ctx, { posX, posY, height, value: this.value.on }); posX += this.hitAreas.toggle.bounds[1] + innerMargin; + // Match normal layers: dim contents when layer is off + if (!this.value.on) { + ctx.globalAlpha = app.canvas.editor_alpha * 0.4; + } + // Name ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; ctx.textAlign = 'left'; @@ -101,11 +106,33 @@ export class HandDrawLayerWidget extends RgthreeBaseWidget { this.hitAreas.repeatAny.bounds = [repeatL[0], repeatR[0] + repeatR[1] - repeatL[0]]; rposX -= drawNumberWidgetPart.WIDTH_TOTAL + 10; - // Draw label in the Interpolation slot (70px) + // Replace the "draw" text with a centered edit icon button occupying the 70px slot const labelWidth = 70; + // Armed only when editor is in per-layer edit mode for this widget + const armed = (node?.editor?._handdrawMode === 'edit') && (node?.editor?._handdrawEditWidget === this); + const slotX = rposX - labelWidth; // left edge of 70px slot + const slotY = posY + (height - (height * 0.8)) / 2; // slight vertical inset + const slotH = height * 0.8; + + ctx.save(); + ctx.fillStyle = armed ? '#0d3b4a' : LiteGraph.WIDGET_BGCOLOR; + ctx.strokeStyle = armed ? '#2cc6ff' : LiteGraph.WIDGET_OUTLINE_COLOR; + ctx.lineWidth = armed ? 2 : 1; + ctx.beginPath(); + ctx.roundRect(slotX, slotY, labelWidth, slotH, [6]); + ctx.fill(); + ctx.stroke(); + // Pencil glyph centered in slot ctx.textAlign = 'center'; - ctx.fillText('draw', rposX - labelWidth / 2, midY); - this.hitAreas.drawLabel.bounds = [rposX - labelWidth, labelWidth]; + ctx.textBaseline = 'middle'; + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.font = `${Math.max(11, Math.min(16, slotH * 0.7))}px monospace`; + ctx.fillText('✏️', slotX + labelWidth / 2, slotY + slotH / 2 + 0.5); + ctx.restore(); + // Entire 70px area is clickable to toggle edit mode + this.hitAreas.drawLabel.bounds = [slotX, labelWidth]; + + // Advance position accounting only for the slot width rposX -= labelWidth + 10; // Z-Pause @@ -170,15 +197,45 @@ export class HandDrawLayerWidget extends RgthreeBaseWidget { ctx.textAlign = 'left'; const nameText = fitString(ctx, this.value.name || 'Handdraw', rposX - posX - innerMargin); ctx.fillText(nameText, posX, midY); - this.hitAreas.name.bounds = [posX, 0, rposX - posX, height]; + // Make name hit area wide and simple (x + width only) to avoid y-mismatch + this.hitAreas.name.bounds = [posX, rposX - posX]; + // Activate layer when clicking the name area + this.hitAreas.name.onDown = (event, pos, node) => { + if (!node.layerManager) return false; + const current = node.layerManager.getActiveWidget?.(); + if (current === this) { + node.layerManager.setActiveWidget(null); + } else { + node.layerManager.setActiveWidget(this); + } + return true; + }; + this.hitAreas.name.onClick = (event, pos, node) => true; // handled onDown ctx.restore(); } - onMouseDown(event, pos, node) { - // Selecting the row should activate this layer + onEditToggle(event, pos, node) { + // Ensure this layer is active if (node.layerManager) { node.layerManager.setActiveWidget(this); } + // Toggle handdraw mode on the editor, mirroring top-row pencil + if (node?.editor) { + if (node.editor._handdrawMode === 'edit' && node.editor._handdrawEditWidget === this) { + node.editor.exitHanddrawMode?.(false); + } else { + node.editor.enterHanddrawMode?.('edit', this); + } + node.setDirtyCanvas(true, true); + // Re-render canvas overlays + try { node.editor.layerRenderer?.render(); } catch {} + } + this.cancelMouseDown?.(); + return true; + } + + onMouseDown(event, pos, node) { + // Activation is handled via hitAreas.name.onDown; do not duplicate here return super.onMouseDown?.(event, pos, node); } @@ -294,20 +351,27 @@ export function commitHanddrawPath(node, points) { const lm = node.layerManager; if (!lm) return; const active = lm.getActiveWidget?.(); + const mode = node?.editor?._handdrawMode || 'off'; + const editWidget = node?.editor?._handdrawEditWidget || null; const isHand = active && active.value?.type === 'handdraw'; const pointsStore = JSON.stringify(points || []); - if (isHand) { - active.value.points_store = pointsStore; + if (mode === 'edit' && editWidget) { + // Replace points in the targeted handdraw layer + editWidget.value.points_store = pointsStore; + // Avoid disarming edit mode due to active-layer refresh + if (node?.editor) node.editor._suppressHanddrawExitOnce = true; + lm.setActiveWidget(editWidget); node.setDirtyCanvas(true, true); if (node.editor?.layerRenderer) node.editor.layerRenderer.render(); if (node.editor?.updatePath) node.editor.updatePath(); - return active; + return editWidget; } - // create new handdraw layer + // In 'create' mode, or fallback: create a new handdraw layer for each stroke if (lm.addNewHanddraw) { - const w = lm.addNewHanddraw('Handdraw'); + const w = lm.addNewHanddraw('Handdraw', /*activate*/ false); w.value.points_store = pointsStore; - // Make the new handdraw layer active + // Suppress one-shot exit of create mode during programmatic switch + if (node?.editor) node.editor._suppressHanddrawExitOnce = true; lm.setActiveWidget(w); node.setDirtyCanvas(true, true); if (node.editor?.layerRenderer) node.editor.layerRenderer.render(); diff --git a/web/power_spline_editor/layer_renderer.js b/web/power_spline_editor/layer_renderer.js index 92b4181..7bb59dd 100644 --- a/web/power_spline_editor/layer_renderer.js +++ b/web/power_spline_editor/layer_renderer.js @@ -18,27 +18,228 @@ export class LayerRenderer { // Store metadata for inactive layers to enable hit detection // Format: [{ widget: widgetRef, points: [...], panel: panelRef }, ...] this.inactiveLayerMetadata = []; + + // Flow animation for inactive layers (non-"points" types) + this._dashAnimOffset = 0; + this._lastDashUpdateMs = 0; + this._dashTimer = setInterval(() => { + this._dashAnimOffset = (this._dashAnimOffset + 2) % 1000; + try { this.updateInactiveDash(); } catch {} + }, 80); + } + + // Apply/update marching-dash on inactive paths via DOM attributes + updateInactiveDash() { + if (!this.splineEditor) return; + const svg = this.vis && this.vis.canvas ? this.vis.canvas() : null; + if (!svg) return; + // If disabled, strip dash styling and stop + if (this.splineEditor._inactiveFlowEnabled === false) { + const allPaths = svg.getElementsByTagName('path'); + for (const p of allPaths) { + if (p.hasAttribute('stroke-dasharray')) p.removeAttribute('stroke-dasharray'); + if (p.hasAttribute('stroke-dashoffset')) p.removeAttribute('stroke-dashoffset'); + if (p.dataset && 'dashOffset' in p.dataset) delete p.dataset.dashOffset; + } + return; + } + const paths = svg.getElementsByTagName('path'); + // If any target path lacks a dasharray, force an immediate apply (skip throttle) + let needsInitialApply = false; + for (const el of paths) { + if (el.getAttribute) { + const stroke = (el.getAttribute('stroke') || '').toLowerCase(); + const sw = Number(el.getAttribute('stroke-width') || '0'); + if ((stroke.includes('120,70,180') || stroke.includes('#7846b4') || stroke.includes('255,127,14') || stroke.includes('#ff7f0e')) && sw > 1 && !el.hasAttribute('stroke-dasharray')) { + needsInitialApply = true; break; + } + } + } + // Throttle dash updates to reduce load during active drawing + const now = Date.now(); + const minInterval = this.splineEditor._handdrawActive ? 120 : 60; // slower while drawing + if (!needsInitialApply && (now - this._lastDashUpdateMs < minInterval)) return; + this._lastDashUpdateMs = now; + const norm = (s) => (s || '').replace(/\s+/g, '').toLowerCase(); + const isInactiveTarget = (p) => { + const stroke = norm(p.getAttribute('stroke')); + const blue = '#1f77b4'; // active normal + const yellow = '#d7c400'; // active handdraw + if (stroke === blue || stroke === '#1f77b4' || stroke === yellow || stroke === '#d7c400') return false; + // Orange inactive (rgba or rgb) or purple inactive + const isOrange = stroke.includes('255,127,14') || stroke.includes('#ff7f0e') || stroke.includes('rgba(255,127,14') || stroke.includes('rgb(255,127,14)'); + const isPurple = stroke.includes('120,70,180') || stroke.includes('#7846b4') || stroke.includes('rgb(120,70,180)'); + if (!(isOrange || isPurple)) return false; + // Exclude thin 1px orange (points type) by stroke-width + const sw = Number(p.getAttribute('stroke-width') || '0'); + if (isOrange && sw <= 1.01) return false; + return true; + }; + // Helper easing functions (return 0..1 shape factor) + const easeValue = (t, mode) => { + t = Math.max(0, Math.min(1, t)); + switch (mode) { + case 'in': return t; // grow along path + case 'out': return 1 - t; // shrink along path + case 'in_out': return Math.sin(Math.PI * t); // squeezed at ends, widest in middle + case 'out_in': return 1 - Math.sin(Math.PI * t); // widest at ends, squeezed middle + case 'linear': + default: return 1; // constant strip size + } + }; + // Split candidates by color to preserve draw order mapping + const phase = ((this._dashAnimOffset % 200) / 200); + const purplePaths = []; + const orangePaths = []; + for (const p of paths) { + if (!isInactiveTarget(p)) continue; + const stroke = norm(p.getAttribute('stroke')); + const isPurple = stroke.includes('120,70,180') || stroke.includes('#7846b4') || stroke.includes('rgb(120,70,180)'); + const list = isPurple ? purplePaths : orangePaths; + const len = (typeof p.getTotalLength === 'function') ? p.getTotalLength() : 0; + list.push({ p, len }); + } + const purpleMetas = (this.inactiveLayerMetadata || []).filter(d => d?.widget?.value?.type === 'handdraw'); + const orangeMetas = (this.inactiveLayerMetadata || []).filter(d => (d?.widget?.value?.type !== 'handdraw') && ((d?.widget?.value?.interpolation || 'linear') !== 'points')); + const applyFor = (pairs, metas) => { + const count = Math.min(pairs.length, metas.length); + for (let idx = 0; idx < count; idx++) { + const { p, len: pathLength } = pairs[idx]; + const easingMode = metas[idx]?.widget?.value?.easing || 'linear'; + const segments = Math.max(14, Math.min(100, Math.round(pathLength / 36))); + const baseDash = 10; + const baseGap = 6; + const minFactor = 0.25; + const maxFactor = 1.6; + const pattern = []; + let sum = 0; + for (let i = 0; i < segments; i++) { + const t = (i / segments + phase) % 1; + const f = easeValue(t, easingMode); + const dash = Math.max(2, baseDash * (minFactor + (maxFactor - minFactor) * f)); + const gap = Math.max(2, baseGap * (1.0 - 0.2 * f)); + pattern.push(dash, gap); + sum += dash + gap; + } + if (sum > 0 && pathLength > 0) { + const scale = pathLength / sum; + for (let k = 0; k < pattern.length; k++) pattern[k] = Math.max(1, pattern[k] * scale); + } + p.setAttribute('stroke-dasharray', pattern.join(' ')); + const dashOffsetPx = -phase * (pathLength || 1); + p.setAttribute('stroke-dashoffset', String(dashOffsetPx)); + } + }; + applyFor(purplePaths, purpleMetas); + applyFor(orangePaths, orangeMetas); + } + + // Apply/update easing-shaped dash for ACTIVE handdraw layer (yellow line) + updateActiveHanddrawDash() { + if (!this.splineEditor || this.splineEditor._inactiveFlowEnabled === false) return; + const now = Date.now(); + const minInterval = this.splineEditor._handdrawActive ? 120 : 60; + if ((this._lastDashUpdateMs || 0) && (now - this._lastDashUpdateMs < minInterval)) return; + this._lastDashUpdateMs = now; + + const svg = this.vis && this.vis.canvas ? this.vis.canvas() : null; + if (!svg) return; + const active = this.node?.layerManager?.getActiveWidget?.(); + if (!active || active.value?.type !== 'handdraw') return; + + const easingMode = active.value?.easing || 'linear'; + const paths = svg.getElementsByTagName('path'); + const norm = (s) => (s || '').replace(/\s+/g, '').toLowerCase(); + const yellow = '#d7c400'; + + // Easing factor + const easeValue = (t, mode) => { + t = Math.max(0, Math.min(1, t)); + switch (mode) { + case 'in': return t; + case 'out': return 1 - t; + case 'in_out': return Math.sin(Math.PI * t); + case 'out_in': return 1 - Math.sin(Math.PI * t); + case 'linear': + default: return 1; + } + }; + + const phase = ((this._dashAnimOffset % 200) / 200); + + for (const p of paths) { + const stroke = norm(p.getAttribute('stroke')); + if (stroke !== yellow && stroke !== '#d7c400') continue; + const pathLength = (typeof p.getTotalLength === 'function') ? p.getTotalLength() : 0; + const segments = Math.max(14, Math.min(100, Math.round(pathLength / 36))); + const baseDash = 10; + const baseGap = 6; + const minFactor = 0.35; + const maxFactor = 1.45; + const pattern = []; + let sum = 0; + for (let i = 0; i < segments; i++) { + const t = (i / segments + phase) % 1; + const f = easeValue(t, easingMode); + const dash = Math.max(2, baseDash * (minFactor + (maxFactor - minFactor) * f)); + const gap = baseGap; + pattern.push(dash, gap); + sum += dash + gap; + } + if (sum > 0 && pathLength > 0) { + const scale = pathLength / sum; + for (let k = 0; k < pattern.length; k++) pattern[k] = Math.max(1, pattern[k] * scale); + } + p.setAttribute('stroke-dasharray', pattern.join(' ')); + const dashOffsetPx = -phase * (pathLength || 1); + p.setAttribute('stroke-dashoffset', String(dashOffsetPx)); + } } /** * Main render method. Clears previous drawings and redraws all layers. */ render() { + // During drag operations, avoid rebuilding the scene graph. + // Recreating marks while Protovis drag behavior is active breaks internal state + // and causes errors like reading undefined 'x'/'fix'. Just re-render in place. + const forceRebuild = !!this.splineEditor._forceRebuildNextRender; + if (forceRebuild) { + this.splineEditor._forceRebuildNextRender = false; + } + if (!forceRebuild && (this.splineEditor.isDragging || this.splineEditor.isDraggingAll || this.splineEditor.isScalingAll || this.splineEditor.isRotatingAll)) { + this.vis.render(); + return; + } + this.clearLayers(); const activeWidget = this.node.layerManager.getActiveWidget(); const allWidgets = this.node.layerManager.getSplineWidgets(); + const isDrawing = !!this.splineEditor._handdrawActive; + const handdrawMode = this.splineEditor._handdrawMode || 'off'; + const activeIsHand = !!(activeWidget && activeWidget.value && activeWidget.value.type === 'handdraw'); // Render inactive and off layers - each in its own isolated panel allWidgets.forEach(widget => { - if (widget !== activeWidget) { + // While drawing in 'create' mode, also render the currently selected layer as inactive + const treatActiveAsInactive = isDrawing && handdrawMode === 'create' && widget === activeWidget; + if (widget !== activeWidget || treatActiveAsInactive) { this.drawInactiveLayer(widget); } }); // Draw the active layer on top in its dedicated panel - if (activeWidget) { + if (activeWidget && !(isDrawing && handdrawMode === 'create')) { + // Normal case: draw the active widget unless we're creating a new handdraw stroke this.drawActiveLayer(activeWidget); + } + if (this.splineEditor._handdrawActive && this.splineEditor.points && this.splineEditor.points.length > 0) { + // During handdraw drawing, render a live preview on top (even if another layer is selected) + // No active widget yet (e.g., first-ever handdraw stroke). Render a live preview + // using handdraw styling so users can see the stroke while drawing. + const pseudoWidget = { value: { interpolation: 'linear', type: 'handdraw' } }; + this.drawActiveLayer(pseudoWidget); } // Ensure active panel is always the last child so it renders on top @@ -51,6 +252,14 @@ export class LayerRenderer { // Apply the changes to the SVG this.vis.render(); + // Avoid extra DOM work while freehand drawing; timer will animate + if (!this.splineEditor._handdrawActive) { + this.updateInactiveDash(); + this.updateActiveHanddrawDash(); + } else { + // still update lazily by throttle + this.updateActiveHanddrawDash(); + } } /** @@ -148,7 +357,11 @@ export class LayerRenderer { } return '#1f77b4'; }) - .lineWidth(() => interpolation === 'points' ? 1 : 3); + .lineWidth(() => { + // Handdraw (active) should have same thickness as normal lines + if (isHanddraw) return 3; + return interpolation === 'points' ? 1 : 3; + }); // ALWAYS draw dots - bigger for points mode, all circles // Use closures to ensure drag handlers can find points via indexOf() @@ -157,7 +370,7 @@ export class LayerRenderer { .left(d => d.x) .top(d => d.y) .radius(() => { - if (isHanddraw) return 4; // smaller points for handdraw + if (isHanddraw) return 2; // make yellow hand-draw points smaller return interpolation === 'points' ? 9 : 6; }) .shape((dot) => { @@ -213,12 +426,12 @@ export class LayerRenderer { drawInactiveLayer(widget) { const isOff = !widget.value.on; const isHanddraw = widget?.value?.type === 'handdraw'; - const strokeColor = isOff + let strokeColor = isOff ? "rgba(255, 255, 255, 0.1)" - : (isHanddraw ? "rgba(70, 40, 110, 0.7)" : "rgba(255, 127, 14, 0.5)"); + : (isHanddraw ? "rgba(120, 70, 180, 0.85)" : "rgba(255, 127, 14, 0.5)"); const fillColor = isOff ? "rgba(255, 255, 255, 0.1)" - : (isHanddraw ? "rgba(70, 40, 110, 0.5)" : "rgba(255, 127, 14, 0.4)"); + : (isHanddraw ? "rgba(120, 70, 180, 0.6)" : "rgba(255, 127, 14, 0.4)"); let points; try { @@ -243,6 +456,10 @@ export class LayerRenderer { }); const interpolation = widget.value.interpolation || 'linear'; + // For inactive points layers (non-handdraw), reduce line opacity to 20% + if (!isHanddraw && !isOff && interpolation === 'points') { + strokeColor = "rgba(255, 127, 14, 0.2)"; + } // Snapshot points to avoid closure issues const pointsSnapshot = [...points]; @@ -285,13 +502,14 @@ export class LayerRenderer { // ALWAYS draw line const lineWidthMain = (interpolation === 'points') ? (isHanddraw ? 3 : 1) : 3; - layerPanel.add(pv.Line).events("none") + const inactiveLine = layerPanel.add(pv.Line).events("none") .data(renderPoints) .left(d => d.x) .top(d => d.y) .interpolate(interpolation === 'points' ? 'linear' : interpolation) .strokeStyle(strokeColor) .lineWidth(lineWidthMain); + // Note: dash animation is applied post-render via updateInactiveDash() // If this is a handdraw layer in inactive (purple) state, render line-only and skip markers/dots if (isHanddraw) { @@ -308,8 +526,17 @@ export class LayerRenderer { .interpolate('linear') .strokeStyle(strokeColor) .lineWidth(lineWidthPts); - // Do not draw circles for inactive layers. Optionally keep first-point triangle for non-handdraw. + // For points-type layers, also show point markers when inactive (non-handdraw only) if (!isHanddraw && pointsSnapshot.length > 0) { + layerPanel.add(pv.Dot).events("none") + .data(pointsSnapshot) + .left(d => d.x) + .top(d => d.y) + .radius(7.0) + .shape('circle') + .strokeStyle(strokeColor) + .fillStyle(fillColor); + // Keep first-point triangle marker for direction layerPanel.add(pv.Dot).events("none") .data([pointsSnapshot[0]]) .left(d => d.x) diff --git a/web/power_spline_editor/power_spline_editor.js b/web/power_spline_editor/power_spline_editor.js index 101b93d..788a8b2 100644 --- a/web/power_spline_editor/power_spline_editor.js +++ b/web/power_spline_editor/power_spline_editor.js @@ -69,7 +69,7 @@ async function saveRefImageToCache(base64Data) { try { // Create a cache key to track the current ref_image const currentHash = await simpleHash(base64Data); - sessionStorage.setItem('spline-editor-ref-image-hash', currentHash); + safeSetSessionItem('spline-editor-ref-image-hash', currentHash); // Try to save the file to the bg folder via the backend API try { @@ -86,7 +86,7 @@ async function saveRefImageToCache(base64Data) { if (result.success) { // Save to sessionStorage as backup - sessionStorage.setItem('spline-editor-cached-ref-image', JSON.stringify({ + safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({ base64: base64Data, type: 'image/png', name: 'ref_image.jpg', @@ -98,7 +98,7 @@ async function saveRefImageToCache(base64Data) { } else { console.error('Backend error saving ref image:', result.error); // Fallback to sessionStorage only - sessionStorage.setItem('spline-editor-cached-ref-image', JSON.stringify({ + safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({ base64: base64Data, type: 'image/png', name: 'ref_image.jpg', @@ -110,7 +110,7 @@ async function saveRefImageToCache(base64Data) { } else { console.error('Failed to save ref image via API:', response.status); // Fallback to sessionStorage only - sessionStorage.setItem('spline-editor-cached-ref-image', JSON.stringify({ + safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({ base64: base64Data, type: 'image/png', name: 'ref_image.jpg', @@ -123,7 +123,7 @@ async function saveRefImageToCache(base64Data) { console.warn('API save failed, using sessionStorage fallback:', error); // Fallback to sessionStorage if API fails - sessionStorage.setItem('spline-editor-cached-ref-image', JSON.stringify({ + safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({ base64: base64Data, type: 'image/png', name: 'ref_image.jpg', @@ -233,17 +233,27 @@ class SplineLayerManager { this.node = node; this.activeWidget = null; this.node.splineWidgetsCounter = 0; + this._noActiveSelection = false; } setActiveWidget(widget) { if (this.activeWidget === widget) { - console.log("[SplineLayerManager] setActiveWidget: already active:", widget?.value?.name); return; } - console.log("[SplineLayerManager] setActiveWidget: switching from", this.activeWidget?.value?.name, "to", widget?.value?.name); + // Track explicit "no selection" state + this._noActiveSelection = (widget == null); + // If the editor is in handdraw create/edit mode, disarm it when switching layers + // Exception: when switch is initiated by handdraw commit flow, one-shot suppress exit + try { + const ed = this.node?.editor; + const suppressOnce = !!ed?._suppressHanddrawExitOnce; + if (ed) ed._suppressHanddrawExitOnce = false; // always reset flag + if (ed && ed._handdrawMode && ed._handdrawMode !== 'off' && !suppressOnce) { + ed.exitHanddrawMode?.(false); + } + } catch {} this.activeWidget = widget; if (this.node.editor) { - console.log("[SplineLayerManager] setActiveWidget: notifying editor"); this.node.editor.onActiveLayerChanged(); // Notify canvas } this.node.setDirtyCanvas(true, true); @@ -254,6 +264,7 @@ class SplineLayerManager { this.activeWidget = null; } if (!this.activeWidget) { + if (this._noActiveSelection) return null; const splines = this.getSplineWidgets(); if (splines.length > 0) { // Find the first visible/active spline widget, not just any spline @@ -331,7 +342,7 @@ class SplineLayerManager { return widget; } - addNewHanddraw(name) { + addNewHanddraw(name, activate = true) { this.node.splineWidgetsCounter++; const widget = new HandDrawLayerWidget("spline_" + this.node.splineWidgetsCounter); @@ -357,11 +368,13 @@ class SplineLayerManager { } this.node.updateNodeHeight(); - this.setActiveWidget(widget); - if (this.node.editor && this.node.editor.vis) { - this.node.editor.vis.render(); + if (activate) { + this.setActiveWidget(widget); + if (this.node.editor && this.node.editor.vis) { + this.node.editor.vis.render(); + } + this.node.setDirtyCanvas(true, true); } - this.node.setDirtyCanvas(true, true); return widget; } @@ -624,6 +637,11 @@ class SplineLayerManager { } } +// Safe sessionStorage setter to avoid quota errors +function safeSetSessionItem(key, value) { + try { sessionStorage.setItem(key, value); } catch (e) { /* ignore quota/unsupported */ } +} + app.registerExtension({ name: 'WanVideoWrapper_QQ.PowerSplineEditor', @@ -826,19 +844,19 @@ app.registerExtension({ } this.hasSplineWidgets = function() { - return this.widgets && this.widgets.some(w => w instanceof PowerSplineWidget); + return this.widgets && this.widgets.some(w => (w instanceof PowerSplineWidget) || (w instanceof HandDrawLayerWidget)); }; this.allSplinesState = function() { - const splineWidgets = this.widgets.filter(w => w instanceof PowerSplineWidget); - if (!splineWidgets.length) return false; - return splineWidgets.every(w => w.value.on); + const layerWidgets = this.widgets.filter(w => (w instanceof PowerSplineWidget) || (w instanceof HandDrawLayerWidget)); + if (!layerWidgets.length) return false; + return layerWidgets.every(w => w.value.on); }; this.toggleAllSplines = function() { - const splineWidgets = this.widgets.filter(w => w instanceof PowerSplineWidget); + const layerWidgets = this.widgets.filter(w => (w instanceof PowerSplineWidget) || (w instanceof HandDrawLayerWidget)); const newState = !this.allSplinesState(); - splineWidgets.forEach(w => w.value.on = newState); + layerWidgets.forEach(w => w.value.on = newState); this.setDirtyCanvas(true, true); }; @@ -1192,8 +1210,6 @@ app.registerExtension({ alert('Could not retrieve reference image from connected node. Make sure an image node is connected to the ref_image input.'); return; } - - console.log('Successfully retrieved reference image from connected node, updating...'); // Store the original reference image separately to use as base for overlays this.originalRefImageData = { @@ -1222,7 +1238,6 @@ app.registerExtension({ this.editor.refreshBackgroundImage(); } - console.log('Reference image updated successfully from connected node'); } catch (error) { console.error('Error updating reference image from connected node:', error); alert('Error updating reference image: ' + error.message); @@ -1818,7 +1833,7 @@ app.registerExtension({ // Also save dimensions to sessionStorage for persistence across page refreshes try { - sessionStorage.setItem(`spline-editor-dims-${this.uuid}`, JSON.stringify({ + safeSetSessionItem(`spline-editor-dims-${this.uuid}`, JSON.stringify({ width: dims.width, height: dims.height })); @@ -1837,7 +1852,7 @@ app.registerExtension({ try { const size = JSON.stringify(this.imgData).length; if (size < 640 * 480) { // 1MB limit - sessionStorage.setItem(`spline-editor-img-${this.uuid}`, JSON.stringify(this.imgData)); + safeSetSessionItem(`spline-editor-img-${this.uuid}`, JSON.stringify(this.imgData)); } else { console.warn("Spline Editor: Image not saved to session storage because it is too large.", size); } diff --git a/web/power_spline_editor/spline_canvas.js b/web/power_spline_editor/spline_canvas.js index f3c546d..fbeab9e 100644 --- a/web/power_spline_editor/spline_canvas.js +++ b/web/power_spline_editor/spline_canvas.js @@ -59,17 +59,52 @@ export default class SplineEditor2 { // Get interpolation from active widget this.interpolation = 'linear'; - // Handdraw mode (UI/UX first pass) - this._handdrawActive = false; + // Handdraw modes: 'off' | 'create' | 'edit' + this._handdrawMode = 'off'; + this._handdrawActive = false; // legacy flag used by renderer + this._handdrawEditWidget = null; this._handdrawPath = []; - this.enterHanddrawMode = () => { + // Global toggle for inactive flow animation (default ON) + this._inactiveFlowEnabled = true; + this.enterHanddrawMode = (mode = 'create', widget = null) => { + this._handdrawMode = mode; + this._handdrawEditWidget = mode === 'edit' ? widget : null; this._handdrawActive = true; this._handdrawPath = []; + + // Install right-click handler to exit edit mode reliably + if (this._teardownHanddrawListeners) { + try { this._teardownHanddrawListeners(); } catch {} + this._teardownHanddrawListeners = null; + } + const canvasEl = this.vis?.canvas?.(); + if ((mode === 'edit' || mode === 'create') && canvasEl) { + const onMouseDown = (ev) => { + // Middle click exits handdraw modes (edit/create), preserving current curve + if (ev && ev.button === 1) { + ev.preventDefault?.(); ev.stopPropagation?.(); + try { this.exitHanddrawMode(false); } catch {} + try { this.layerRenderer?.render(); } catch {} + try { this.node?.setDirtyCanvas?.(true, true); } catch {} + } + }; + canvasEl.addEventListener('mousedown', onMouseDown, true); + this._teardownHanddrawListeners = () => { + canvasEl.removeEventListener('mousedown', onMouseDown, true); + }; + } }; this.exitHanddrawMode = (commit = false) => { - // Only cancel UI state; no commit normalization here (handled in mousedown mouseup flow) + // Only cancel UI state; no commit normalization here (handled in mousedown/mouseup flow) + this._handdrawMode = 'off'; + this._handdrawEditWidget = null; this._handdrawActive = false; this._handdrawPath = []; + // Remove temporary listeners + if (this._teardownHanddrawListeners) { + try { this._teardownHanddrawListeners(); } catch {} + this._teardownHanddrawListeners = null; + } // Re-render to update any UI state try { this.layerRenderer?.render(); } catch {} }; @@ -333,8 +368,9 @@ export default class SplineEditor2 { // Normalize and commit const norm = this.normalizePoints(simplified); try { this.node?.commitHanddraw?.(norm); } catch {} - this._handdrawActive = false; + // After committing, remain in current mode; clear temporary path this._handdrawPath = []; + this._handdrawActive = (this._handdrawMode !== 'off'); // Reload active points from widget and re-render this.points = this.getActivePoints(); this.layerRenderer.render(); @@ -408,10 +444,19 @@ export default class SplineEditor2 { this.updatePath(); } else if (pv.event.button === 2) { - // Open custom canvas context menu and suppress native one + // Right-click behavior: open custom canvas context menu and suppress native one try { pv.event.preventDefault?.(); pv.event.stopPropagation?.(); } catch {} const menuEl = this.node.contextMenu; + // Update first menu item label based on active layer type + try { + const activeWidget = this.getActiveWidget?.(); + const isHanddraw = !!(activeWidget && activeWidget.value && activeWidget.value.type === 'handdraw'); + const firstItem = this.node.menuItems && this.node.menuItems[0]; + if (firstItem) { + firstItem.textContent = isHanddraw ? 'Edit' : 'Invert point order'; + } + } catch {} menuEl.style.display = 'block'; menuEl.style.left = `${pv.event.clientX}px`; menuEl.style.top = `${pv.event.clientY}px`; @@ -445,6 +490,13 @@ export default class SplineEditor2 { document.addEventListener('keydown', onEsc, true); }, 0); } + // Middle click: exit any handdraw mode (edit/create) and keep current edits + else if (pv.event.button === 1) { + try { this.exitHanddrawMode(false); } catch {} + try { this.layerRenderer?.render(); } catch {} + try { this.node?.setDirtyCanvas?.(true, true); } catch {} + return this; + } }) this.dragStartHandler = (d) => { @@ -479,10 +531,21 @@ export default class SplineEditor2 { return; } - // Alt+Middle-Click: Translation (any point) + // Alt + Middle-Click: Translation (any point) if (pv.event.altKey && pv.event.button === 1) { this.isDraggingAll = true; this.dragStartPos = { x: mouseX, y: mouseY }; + // Snapshot original positions to keep a rigid translation anchored at clicked point + this.translateAllSnapshot = { + points: this.points.map(p => ({ ...p })), + pivotIndex: this.i, + pivotX: this.points[this.i].x, + pivotY: this.points[this.i].y, + offsetX: this.dragOffset ? this.dragOffset.x : 0, + offsetY: this.dragOffset ? this.dragOffset.y : 0, + }; + // Provide a neutral fix object so Protovis drag doesn't apply extra offsets + try { if (dot && typeof dot === 'object') { dot.fix = { x: 0, y: 0 }; } } catch {} this.isDragging = true; return; } @@ -505,6 +568,7 @@ export default class SplineEditor2 { this.isDragging = true; if (pv.event.button === 2 && this.i !== 0 && this.i !== this.points.length - 1) { this.points.splice(this.i--, 1); + this._forceRebuildNextRender = true; this.layerRenderer.render(); } }; @@ -516,6 +580,9 @@ export default class SplineEditor2 { if (this.pathElements !== null) { this.updatePath(); } + // Clear any protovis drag fix offsets left on data objects + try { if (Array.isArray(this.points)) { for (const p of this.points) { if (p && p.fix !== undefined) delete p.fix; } } } catch {} + this.translateAllSnapshot = null; this.dragOffset = null; this.isDragging = false; this.isDraggingAll = false; @@ -541,20 +608,20 @@ export default class SplineEditor2 { // Rotate all points around the anchor point for (let j = 0; j < this.originalPoints.length; j++) { + const originalPoint = this.originalPoints[j]; if (j === this.anchorIndex) { // Keep anchor point fixed - this.points[j] = { ...this.anchorPoint }; + this.points[j].x = this.anchorPoint.x; + this.points[j].y = this.anchorPoint.y; + this.points[j].highlighted = !!originalPoint.highlighted; } else { - const originalPoint = this.originalPoints[j]; const vecX = originalPoint.x - this.anchorPoint.x; const vecY = originalPoint.y - this.anchorPoint.y; const rotatedX = vecX * cos - vecY * sin; const rotatedY = vecX * sin + vecY * cos; - this.points[j] = { - x: this.anchorPoint.x + rotatedX, - y: this.anchorPoint.y + rotatedY, - highlighted: originalPoint.highlighted || false - }; + this.points[j].x = this.anchorPoint.x + rotatedX; + this.points[j].y = this.anchorPoint.y + rotatedY; + this.points[j].highlighted = !!originalPoint.highlighted; } } this.layerRenderer.render(); @@ -562,15 +629,22 @@ export default class SplineEditor2 { } if (this.isDraggingAll && this.dragStartPos) { - const deltaX = adjustedX - this.dragStartPos.x; - const deltaY = adjustedY - this.dragStartPos.y; - this.points = this.points.map(point => ({ - x: point.x + deltaX, - y: point.y + deltaY, - highlighted: point.highlighted || false - })); - this.dragStartPos.x = adjustedX; - this.dragStartPos.y = adjustedY; + // Rigid translation anchored at originally clicked point using snapshot + const snap = this.translateAllSnapshot; + const desiredX = adjustedX + (snap ? snap.offsetX : (this.dragOffset ? this.dragOffset.x : 0)); + const desiredY = adjustedY + (snap ? snap.offsetY : (this.dragOffset ? this.dragOffset.y : 0)); + const basePivotX = snap ? snap.pivotX : this.dragStartPos.x; + const basePivotY = snap ? snap.pivotY : this.dragStartPos.y; + const deltaX = desiredX - basePivotX; + const deltaY = desiredY - basePivotY; + try { if (d && typeof d === 'object') { d.fix = { x: 0, y: 0 }; } } catch {} + const basePoints = snap ? snap.points : this.points; + for (let j = 0; j < this.points.length; j++) { + const bp = basePoints[j] || this.points[j]; + this.points[j].x = bp.x + deltaX; + this.points[j].y = bp.y + deltaY; + this.points[j].highlighted = !!this.points[j].highlighted; + } this.layerRenderer.render(); return; } @@ -584,18 +658,18 @@ export default class SplineEditor2 { // Scale all points relative to the anchor point for (let j = 0; j < this.originalPoints.length; j++) { + const originalPoint = this.originalPoints[j]; if (j === this.anchorIndex) { // Keep anchor point fixed - this.points[j] = { ...this.anchorPoint }; + this.points[j].x = this.anchorPoint.x; + this.points[j].y = this.anchorPoint.y; + this.points[j].highlighted = !!originalPoint.highlighted; } else { - const originalPoint = this.originalPoints[j]; const vecX = originalPoint.x - this.anchorPoint.x; const vecY = originalPoint.y - this.anchorPoint.y; - this.points[j] = { - x: this.anchorPoint.x + vecX * clampedScaleFactor, - y: this.anchorPoint.y + vecY * clampedScaleFactor, - highlighted: originalPoint.highlighted || false - }; + this.points[j].x = this.anchorPoint.x + vecX * clampedScaleFactor; + this.points[j].y = this.anchorPoint.y + vecY * clampedScaleFactor; + this.points[j].highlighted = !!originalPoint.highlighted; } } this.layerRenderer.render(); @@ -604,11 +678,9 @@ export default class SplineEditor2 { if (!this.isDraggingAll && !this.isScalingAll && !this.isRotatingAll) { if (this.dragOffset) { - this.points[this.i] = { - x: adjustedX + this.dragOffset.x, - y: adjustedY + this.dragOffset.y, - highlighted: this.points[this.i].highlighted || false - }; + this.points[this.i].x = adjustedX + this.dragOffset.x; + this.points[this.i].y = adjustedY + this.dragOffset.y; + this.points[this.i].highlighted = !!this.points[this.i].highlighted; } this.layerRenderer.render(); } @@ -616,14 +688,16 @@ export default class SplineEditor2 { this.mouseOverHandler = (d) => { this.hoverIndex = this.points.indexOf(d); - this.layerRenderer.render(); + // Avoid rebuilding the scene on hover; a simple re-render is sufficient + if (this.layerRenderer?.vis) this.layerRenderer.vis.render(); }; this.mouseOutHandler = () => { if (!this.isDragging) { this.hoverIndex = -1; } - this.layerRenderer.render(); + // Avoid rebuilding the scene on hover out + if (this.layerRenderer?.vis) this.layerRenderer.vis.render(); }; this.backgroundImage = this.vis.add(pv.Image).visible(false) @@ -1056,10 +1130,19 @@ export default class SplineEditor2 { document.addEventListener('contextmenu', hideOpenMenus, true); switch (index) { case 0: - // Invert point order + // Context action depends on active layer type e.preventDefault(); - this.points.reverse(); - this.updatePath(); + const aw = this.getActiveWidget?.(); + const isHand = !!(aw && aw.value && aw.value.type === 'handdraw'); + if (isHand) { + // Enter edit mode for the active handdraw layer + try { this.enterHanddrawMode('edit', aw); } catch {} + try { this.layerRenderer?.render(); } catch {} + } else { + // Invert point order for non-handdraw layers + this.points.reverse(); + this.updatePath(); + } this.node.contextMenu.style.display = 'none'; break; case 1: diff --git a/web/power_spline_editor/spline_utils.js b/web/power_spline_editor/spline_utils.js index d4e2fae..a4b4e95 100644 --- a/web/power_spline_editor/spline_utils.js +++ b/web/power_spline_editor/spline_utils.js @@ -153,7 +153,13 @@ export class TopRowWidget extends RgthreeBaseWidget { const refreshButtonWidth = availableWidth * 0.15; const bgImgDropdownWidth = availableWidth * 0.15; const drawButtonWidth = Math.max(20, Math.min(28, availableWidth * 0.05)); - const dimensionsAreaWidth = availableWidth - (refreshButtonWidth + spacing + bgImgDropdownWidth + spacing + drawButtonWidth + spacing); + // Account for both the pencil button and the animation toggle button + const dimensionsAreaWidth = availableWidth - ( + refreshButtonWidth + spacing + + bgImgDropdownWidth + spacing + + drawButtonWidth + spacing + // ✏️ + drawButtonWidth + spacing // 〰 + ); // Calculate total width and starting position to center everything const totalWidth = refreshButtonWidth + spacing + bgImgDropdownWidth + spacing + dimensionsAreaWidth; @@ -199,7 +205,8 @@ export class TopRowWidget extends RgthreeBaseWidget { posX += bgImgDropdownWidth + spacing; // Small Draw icon button between overlay picker and dimensions - const isArmed = !!(node?.editor?._handdrawActive); + // Armed when in create-new-stroke mode (separate from per-layer edit) + const isArmed = (node?.editor?._handdrawMode === 'create'); drawWidgetButton( ctx, { size: [drawButtonWidth, height], pos: [posX, posY] }, @@ -208,29 +215,58 @@ export class TopRowWidget extends RgthreeBaseWidget { ); // Add blue outline when armed to match active layer style if (isArmed) { - // Slightly inset outline to overlay nicely + // Slightly inset outline to overlay nicely; match thickness with layer edit button (2px) const pad = 0.5; - drawRoundedRectangle(ctx, { - pos: [posX + pad, posY + pad], - size: [drawButtonWidth - pad * 2, height - pad * 2], - colorBackground: 'transparent', - colorStroke: '#2cc6ff', - borderRadius: 4 - }); + ctx.save(); + ctx.strokeStyle = '#2cc6ff'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.roundRect(posX + pad, posY + pad, drawButtonWidth - pad * 2, height - pad * 2, [4]); + ctx.stroke(); + ctx.restore(); } this.hitAreas.drawButton = { bounds: [posX, drawButtonWidth], onClick: (e, p, n) => { // Toggle draw mode on the editor; canvas will handle capture on mousedown if (n?.editor) { - if (n.editor._handdrawActive) { + if (n.editor._handdrawMode === 'create') { n.editor.exitHanddrawMode?.(false); } else { - n.editor.enterHanddrawMode?.(); + // enter create-new-stroke mode (each stroke creates a new handdraw layer) + n.editor.enterHanddrawMode?.('create'); } n.setDirtyCanvas(true, true); } return true; }}; posX += drawButtonWidth + spacing; + + // Animation toggle icon for inactive flow animation (default ON) + const isAnimOn = !!(node?.editor?._inactiveFlowEnabled ?? true); + drawWidgetButton( + ctx, + { size: [drawButtonWidth, height], pos: [posX, posY] }, + "〰", // wavy line icon + isAnimOn + ); + if (isAnimOn) { + const pad = 0.5; + ctx.save(); + ctx.strokeStyle = '#2cc6ff'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.roundRect(posX + pad, posY + pad, drawButtonWidth - pad * 2, height - pad * 2, [4]); + ctx.stroke(); + ctx.restore(); + } + this.hitAreas.animToggleButton = { bounds: [posX, drawButtonWidth], onClick: (e, p, n) => { + if (n?.editor) { + n.editor._inactiveFlowEnabled = !n.editor._inactiveFlowEnabled; + try { n.editor.layerRenderer?.updateInactiveDash?.(); } catch {} + n.setDirtyCanvas(true, true); + } + return true; + }}; + posX += drawButtonWidth + spacing; // Draw rounded area for width/height controls const roundedAreaX = posX; diff --git a/web/power_spline_editor/widget_utils.js b/web/power_spline_editor/widget_utils.js index 981b419..b8b9dd1 100644 --- a/web/power_spline_editor/widget_utils.js +++ b/web/power_spline_editor/widget_utils.js @@ -267,20 +267,27 @@ export class PowerSplineWidget extends RgthreeBaseWidget { ctx.textAlign = "left"; const nameText = fitString(ctx, this.value.name || "Spline", rposX - posX - innerMargin); ctx.fillText(nameText, posX, midY); - this.hitAreas.name.bounds = [posX, 0, rposX - posX, height]; + // Make name hit area wide and simple (x + width only) to avoid y-mismatch + this.hitAreas.name.bounds = [posX, rposX - posX]; + // Activate layer when clicking the name area (onDown for immediacy) + this.hitAreas.name.onDown = (event, pos, node) => { + if (!node.layerManager) return false; + const current = node.layerManager.getActiveWidget?.(); + // Toggle off if clicking the currently active layer name + if (current === this) { + node.layerManager.setActiveWidget(null); + } else { + node.layerManager.setActiveWidget(this); + } + return true; + }; + this.hitAreas.name.onClick = (event, pos, node) => true; // handled onDown ctx.restore(); } onMouseDown(event, pos, node) { - // Check if the click was on the toggle button - if (this.hitAreas.toggle && this.clickWasWithinBounds(pos, this.hitAreas.toggle.bounds)) { - // If it was on the toggle, do not set active widget. - // The onToggleDown handler will take care of the toggle action. - } else if (node.layerManager) { - // Otherwise, set this widget as active - node.layerManager.setActiveWidget(this); - } + // Activation is handled via hitAreas.name.onDown; do not duplicate here return super.onMouseDown?.(event, pos, node); } @@ -508,11 +515,7 @@ export class PowerSplineWidget extends RgthreeBaseWidget { onMouseUp(event, pos, node) { super.onMouseUp(event, pos, node); this.haveMouseMovedValue = false; - - // Set this widget as active when clicked - if (node.layerManager) { - node.layerManager.setActiveWidget(this); - } + // Activation handled by hitAreas; no action here } onDrivenToggleDown(event, pos, node) {