From 99d9f7bdc3244a7a04efd97669bc07e2fb51346c Mon Sep 17 00:00:00 2001 From: Mitchell Hansen Date: Fri, 8 May 2026 23:26:11 -0700 Subject: [PATCH] gcode preview: fix drag-induced regen storm + wheel zoom over handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the new drag-handles UI, fixed together: 1. Stroke regen during drags. Two interlocking causes: - Viewport.draw() called setLayoutTick on every invocation so the SVG overlay would re-render with new pan/zoom positions. But draw() is also called by the chunked-stroke renderer hundreds of times per process_pass — bumping there scheduled re-renders that queued and didn't drain under sustained drag (~60Hz setGcode). Fix: take the tick bump out of draw entirely. Bump only from wheel / canvas-pan / resize. Render body recomputes layout fresh from stateRef + gcodeConfigRef each render; layoutRef stays as a write-only ref so drag closures can read live spm. - App.canvasDims was a `{ width, height }` object literal rebuilt every render. Viewport receives it as `imgSize`, which is in `draw`'s useCallback deps; so each gcodeConfig change made `draw` a new reference, which made the chunked-stroke renderer's effect (with `draw` in its deps) cancel its in-flight rAF and restart from idx 0 — visible as the strokes vanishing and rebuilding every drag tick. Fix: useMemo canvasDims keyed on paper_w_mm / paper_h_mm / dpi. Drag changes to paper_offset_x/y or offset_x/y don't touch those, so identity is stable and the chunk renderer keeps running. 2. Scroll-to-zoom dead over the handles. The paper/image/corner rects set pointer-events:auto so they capture click-drag, which also captured wheel events and blocked the canvas's onWheel. Fix: add onWheel to the SVG overlay element itself. The SVG has pointer-events:none so wheel-over-empty-space falls through to the canvas; wheel-over-handles fires on the rect (pointer-events:auto) and bubbles up to the SVG's onWheel, which calls the same handler the canvas uses. --- src-frontend/src/App.jsx | 14 ++-- src-frontend/src/components/Viewport.jsx | 82 ++++++++++++++++-------- 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index 6f1d6b61..94e621b6 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useRef } from 'react' +import { useState, useCallback, useEffect, useMemo, useRef } from 'react' import Viewport from './components/Viewport.jsx' import PrinterPanel from './components/PrinterPanel.jsx' import TuningPanel from './components/TuningPanel.jsx' @@ -36,12 +36,16 @@ export default function App() { const resizing = useRef(false) const hasOutput = passes.some(p => p.strokeCount > 0) - // Synthesised image-info for components that still want { width, height } - // — derives the paper-pixel canvas the pipeline operates on. - const canvasDims = { + // Synthesised image-info for components that still want { width, height }. + // useMemo keeps the object identity stable across renders — Viewport + // takes this as `imgSize` which feeds into its `draw` useCallback's + // deps; without memoisation, every gcodeConfig drag tick would + // recreate the object, recreate `draw`, and restart Viewport's + // chunked-stroke renderer mid-render. + const canvasDims = useMemo(() => ({ width: Math.round(gcodeConfig.paper_w_mm * dpi / 25.4), height: Math.round(gcodeConfig.paper_h_mm * dpi / 25.4), - } + }), [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm, dpi]) // Has a Source node with a loaded file path? Used for the empty-state overlay. const anyLoadedSource = (passes[0]?.graph?.nodes ?? []) .some(n => n.kind === 'Source' && n.file_path) diff --git a/src-frontend/src/components/Viewport.jsx b/src-frontend/src/components/Viewport.jsx index 25d0a677..e80d7ddf 100644 --- a/src-frontend/src/components/Viewport.jsx +++ b/src-frontend/src/components/Viewport.jsx @@ -12,16 +12,32 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo const svgImgRef = useRef(null) const stateRef = useRef({ zoom: 1, pan: { x: 0, y: 0 }, dragging: false, last: null }) - // Bumped after every draw — forces the SVG overlay to recompute its - // screen-pixel positions when pan/zoom (held in refs for perf) change. + // Bumped from events that actually move things on screen (wheel, canvas + // pan, resize) so the SVG overlay can recompute via a fresh layout() + // call in the render body. Crucially NOT bumped from draw() itself — + // draw is called by the chunked-stroke renderer hundreds of times per + // process_pass, and bumping there caused re-render storms. const [layoutTick, setLayoutTick] = useState(0) - const layoutRef = useRef({ iw: 0, ih: 0, scale: 1, ox: 0, oy: 0, spm: 0 }) + const bumpLayout = useCallback(() => setLayoutTick(t => (t + 1) & 0xffff), []) + + // Live layout snapshot — written by layout() on every call, read by + // overlay drag handlers (move(ev) needs current spm to convert mouse + // pixels into mm). Updating this ref does NOT trigger a re-render; + // it's just a way to share the latest layout with the drag closures. + const layoutRef = useRef({ mode: 'image', iw: 0, ih: 0, scale: 1, ox: 0, oy: 0, spm: 0 }) // Drag state for the gcode overlay handles. Lives outside React so // mousemove writes don't cause render storms; we commit to gcodeConfig // via setGcode on each delta tick. const handleDragRef = useRef(null) // { kind, startX, startY, startVals } + // gcodeConfig is read by layout() but kept OUT of draw's deps so + // that the chunked-stroke offscreen renderer (which depends on `draw`) + // doesn't restart from scratch every time the user drags a handle — + // each drag tick mutates gcodeConfig hundreds of times. + const gcodeConfigRef = useRef(gcodeConfig) + gcodeConfigRef.current = gcodeConfig + // Offscreen canvas: strokes are drawn here incrementally; main canvas blits it const offscreenRef = useRef(null) // Chunked draw state — lives outside React state to avoid re-renders @@ -44,26 +60,27 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo const useStrokeDims = strokes && (viewMode === 'gcode' || viewMode === 'fill') const iw = useStrokeDims ? (strokes.img_width ?? imgSize?.width ?? 512) : (imgSize?.width ?? 512) const ih = useStrokeDims ? (strokes.img_height ?? imgSize?.height ?? 512) : (imgSize?.height ?? 512) + const cfg = gcodeConfigRef.current - if (viewMode === 'gcode' && gcodeConfig && gcodeConfig.bed_w_mm > 0) { + if (viewMode === 'gcode' && cfg && cfg.bed_w_mm > 0) { // Bed-anchored layout. spm = screen pixels per mm. - const fit_mm = Math.min(W / gcodeConfig.bed_w_mm, H / gcodeConfig.bed_h_mm) * 0.92 + const fit_mm = Math.min(W / cfg.bed_w_mm, H / cfg.bed_h_mm) * 0.92 const spm = fit_mm * zoom - const bed_x = W / 2 - gcodeConfig.bed_w_mm * spm / 2 + pan.x - const bed_y = H / 2 - gcodeConfig.bed_h_mm * spm / 2 + pan.y - const paper_x = bed_x + (gcodeConfig.paper_offset_x_mm ?? 0) * spm - const paper_y = bed_y + (gcodeConfig.paper_offset_y_mm ?? 0) * spm - const image_x = paper_x + (gcodeConfig.offset_x_mm ?? 0) * spm - const image_y = paper_y + (gcodeConfig.offset_y_mm ?? 0) * spm - const image_w_screen = (gcodeConfig.img_w_mm ?? 0) * spm + const bed_x = W / 2 - cfg.bed_w_mm * spm / 2 + pan.x + const bed_y = H / 2 - cfg.bed_h_mm * spm / 2 + pan.y + const paper_x = bed_x + (cfg.paper_offset_x_mm ?? 0) * spm + const paper_y = bed_y + (cfg.paper_offset_y_mm ?? 0) * spm + const image_x = paper_x + (cfg.offset_x_mm ?? 0) * spm + const image_y = paper_y + (cfg.offset_y_mm ?? 0) * spm + const image_w_screen = (cfg.img_w_mm ?? 0) * spm // Image height in mm derived from pixel aspect — matches Rust's // GcodeConfig::img_h_mm. Image at-paper height in screen px. - const img_h_mm = iw > 0 ? (ih / iw) * (gcodeConfig.img_w_mm ?? 0) : 0 + const img_h_mm = iw > 0 ? (ih / iw) * (cfg.img_w_mm ?? 0) : 0 const image_h_screen = img_h_mm * spm // Stroke pixel → screen pixel scale (used by canvas drawImage of the // offscreen stroke canvas onto the image rect). const stroke_scale = iw > 0 ? image_w_screen / iw : 0 - return { + const out = { mode: 'gcode', iw, ih, spm, bed_x, bed_y, @@ -72,6 +89,8 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo // Aliases used by the strokes-canvas drawImage path: ox: image_x, oy: image_y, scale: stroke_scale, } + layoutRef.current = out + return out } // Image-anchored layout (source / pipeline / fill etc.) @@ -79,7 +98,9 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo const scale = fit * zoom const ox = W / 2 - iw * scale / 2 + pan.x const oy = H / 2 - ih * scale / 2 + pan.y - return { mode: 'image', iw, ih, scale, ox, oy, spm: 0 } + const out = { mode: 'image', iw, ih, scale, ox, oy, spm: 0 } + layoutRef.current = out + return out } // ── Main canvas draw — just composites whatever is in the offscreen ─────── @@ -89,9 +110,6 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo const ctx = canvas.getContext('2d') const L = layout(canvas) const { iw, ih, scale, ox, oy } = L - // Snapshot for SVG overlay; React tick triggers overlay re-render. - layoutRef.current = L - setLayoutTick(t => (t + 1) & 0xffff) ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.fillStyle = '#0f0f0f' @@ -150,7 +168,9 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo } } } - }, [imageB64, strokes, imgSize, viewMode, gcodeConfig]) + }, [imageB64, strokes, imgSize, viewMode]) // gcodeConfig deliberately + // omitted — read via gcodeConfigRef so handle drags don't recreate + // `draw` and restart the chunked-stroke renderer. // ── Chunked offscreen stroke rendering ──────────────────────────────────── useEffect(() => { @@ -260,6 +280,11 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo useEffect(() => { draw() }, [draw]) + // Redraw whenever gcodeConfig changes (handle drags, paper-size changes, + // etc.) — calls the same `draw` reference so the chunked-stroke renderer + // doesn't see a deps change and stays uninterrupted. + useEffect(() => { draw() }, [gcodeConfig, draw]) + // ── Zoom to cursor ──────────────────────────────────────────────────────── function onWheel(e) { e.preventDefault() @@ -273,6 +298,7 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo stateRef.current.pan.x = pan.x * factor + dx * (1 - factor) stateRef.current.pan.y = pan.y * factor + dy * (1 - factor) draw() + bumpLayout() } function onMouseDown(e) { @@ -287,6 +313,7 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo stateRef.current.pan.y += e.clientY - stateRef.current.last.y stateRef.current.last = { x: e.clientX, y: e.clientY } draw() + bumpLayout() } function onMouseUp() { stateRef.current.dragging = false } @@ -358,18 +385,22 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo canvas.width = rect.width canvas.height = rect.height draw() + bumpLayout() }) ro.observe(canvas.parentElement) return () => ro.disconnect() }, [draw]) - // Re-read of layoutRef on every render via layoutTick — keeps the SVG - // overlay in sync with canvas pan/zoom (which live in refs for perf). - const L = layoutRef.current - const showOverlay = viewMode === 'gcode' && gcodeConfig && L.mode === 'gcode' && L.spm > 0 + // Compute layout fresh on every render — `layoutTick` (bumped from + // wheel / canvas pan / resize) plus prop changes (gcodeConfig) drive + // re-renders. Layout reads pan/zoom from stateRef and gcodeConfig from + // its ref, so it's always current. + void layoutTick // satisfy lint that layoutTick is read; render must depend on it + const _canvas = canvasRef.current + const L = _canvas ? layout(_canvas) : null + const showOverlay = !!L && viewMode === 'gcode' && gcodeConfig && L.mode === 'gcode' && L.spm > 0 let overlay = null if (showOverlay) { - void layoutTick // ensure render depends on the tick const bedW = gcodeConfig.bed_w_mm * L.spm const bedH = gcodeConfig.bed_h_mm * L.spm const paperW = gcodeConfig.paper_w_mm * L.spm @@ -381,7 +412,8 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo { id: 'br', cx: L.image_x + L.image_w_screen, cy: L.image_y + L.image_h_screen, cur: 'nwse-resize' }, ] overlay = ( - + {/* Bed — informational only, no drag */}