gcode preview: fix drag-induced regen storm + wheel zoom over handles

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.
This commit is contained in:
Mitchell Hansen
2026-05-08 23:26:11 -07:00
parent ec36adb8a3
commit 99d9f7bdc3
2 changed files with 66 additions and 30 deletions

View File

@@ -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 Viewport from './components/Viewport.jsx'
import PrinterPanel from './components/PrinterPanel.jsx' import PrinterPanel from './components/PrinterPanel.jsx'
import TuningPanel from './components/TuningPanel.jsx' import TuningPanel from './components/TuningPanel.jsx'
@@ -36,12 +36,16 @@ export default function App() {
const resizing = useRef(false) const resizing = useRef(false)
const hasOutput = passes.some(p => p.strokeCount > 0) const hasOutput = passes.some(p => p.strokeCount > 0)
// Synthesised image-info for components that still want { width, height } // Synthesised image-info for components that still want { width, height }.
// — derives the paper-pixel canvas the pipeline operates on. // useMemo keeps the object identity stable across renders — Viewport
const canvasDims = { // 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), width: Math.round(gcodeConfig.paper_w_mm * dpi / 25.4),
height: Math.round(gcodeConfig.paper_h_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. // Has a Source node with a loaded file path? Used for the empty-state overlay.
const anyLoadedSource = (passes[0]?.graph?.nodes ?? []) const anyLoadedSource = (passes[0]?.graph?.nodes ?? [])
.some(n => n.kind === 'Source' && n.file_path) .some(n => n.kind === 'Source' && n.file_path)

View File

@@ -12,16 +12,32 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
const svgImgRef = useRef(null) const svgImgRef = useRef(null)
const stateRef = useRef({ zoom: 1, pan: { x: 0, y: 0 }, dragging: false, last: 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 // Bumped from events that actually move things on screen (wheel, canvas
// screen-pixel positions when pan/zoom (held in refs for perf) change. // 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 [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 // Drag state for the gcode overlay handles. Lives outside React so
// mousemove writes don't cause render storms; we commit to gcodeConfig // mousemove writes don't cause render storms; we commit to gcodeConfig
// via setGcode on each delta tick. // via setGcode on each delta tick.
const handleDragRef = useRef(null) // { kind, startX, startY, startVals } 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 // Offscreen canvas: strokes are drawn here incrementally; main canvas blits it
const offscreenRef = useRef(null) const offscreenRef = useRef(null)
// Chunked draw state — lives outside React state to avoid re-renders // 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 useStrokeDims = strokes && (viewMode === 'gcode' || viewMode === 'fill')
const iw = useStrokeDims ? (strokes.img_width ?? imgSize?.width ?? 512) : (imgSize?.width ?? 512) 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 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. // 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 spm = fit_mm * zoom
const bed_x = W / 2 - gcodeConfig.bed_w_mm * spm / 2 + pan.x const bed_x = W / 2 - cfg.bed_w_mm * spm / 2 + pan.x
const bed_y = H / 2 - gcodeConfig.bed_h_mm * spm / 2 + pan.y const bed_y = H / 2 - cfg.bed_h_mm * spm / 2 + pan.y
const paper_x = bed_x + (gcodeConfig.paper_offset_x_mm ?? 0) * spm const paper_x = bed_x + (cfg.paper_offset_x_mm ?? 0) * spm
const paper_y = bed_y + (gcodeConfig.paper_offset_y_mm ?? 0) * spm const paper_y = bed_y + (cfg.paper_offset_y_mm ?? 0) * spm
const image_x = paper_x + (gcodeConfig.offset_x_mm ?? 0) * spm const image_x = paper_x + (cfg.offset_x_mm ?? 0) * spm
const image_y = paper_y + (gcodeConfig.offset_y_mm ?? 0) * spm const image_y = paper_y + (cfg.offset_y_mm ?? 0) * spm
const image_w_screen = (gcodeConfig.img_w_mm ?? 0) * spm const image_w_screen = (cfg.img_w_mm ?? 0) * spm
// Image height in mm derived from pixel aspect — matches Rust's // Image height in mm derived from pixel aspect — matches Rust's
// GcodeConfig::img_h_mm. Image at-paper height in screen px. // 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 const image_h_screen = img_h_mm * spm
// Stroke pixel → screen pixel scale (used by canvas drawImage of the // Stroke pixel → screen pixel scale (used by canvas drawImage of the
// offscreen stroke canvas onto the image rect). // offscreen stroke canvas onto the image rect).
const stroke_scale = iw > 0 ? image_w_screen / iw : 0 const stroke_scale = iw > 0 ? image_w_screen / iw : 0
return { const out = {
mode: 'gcode', mode: 'gcode',
iw, ih, spm, iw, ih, spm,
bed_x, bed_y, 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: // Aliases used by the strokes-canvas drawImage path:
ox: image_x, oy: image_y, scale: stroke_scale, ox: image_x, oy: image_y, scale: stroke_scale,
} }
layoutRef.current = out
return out
} }
// Image-anchored layout (source / pipeline / fill etc.) // 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 scale = fit * zoom
const ox = W / 2 - iw * scale / 2 + pan.x const ox = W / 2 - iw * scale / 2 + pan.x
const oy = H / 2 - ih * scale / 2 + pan.y 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 ─────── // ── 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 ctx = canvas.getContext('2d')
const L = layout(canvas) const L = layout(canvas)
const { iw, ih, scale, ox, oy } = L 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.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#0f0f0f' 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 ──────────────────────────────────── // ── Chunked offscreen stroke rendering ────────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -260,6 +280,11 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
useEffect(() => { draw() }, [draw]) 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 ──────────────────────────────────────────────────────── // ── Zoom to cursor ────────────────────────────────────────────────────────
function onWheel(e) { function onWheel(e) {
e.preventDefault() 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.x = pan.x * factor + dx * (1 - factor)
stateRef.current.pan.y = pan.y * factor + dy * (1 - factor) stateRef.current.pan.y = pan.y * factor + dy * (1 - factor)
draw() draw()
bumpLayout()
} }
function onMouseDown(e) { 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.pan.y += e.clientY - stateRef.current.last.y
stateRef.current.last = { x: e.clientX, y: e.clientY } stateRef.current.last = { x: e.clientX, y: e.clientY }
draw() draw()
bumpLayout()
} }
function onMouseUp() { stateRef.current.dragging = false } function onMouseUp() { stateRef.current.dragging = false }
@@ -358,18 +385,22 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
canvas.width = rect.width canvas.width = rect.width
canvas.height = rect.height canvas.height = rect.height
draw() draw()
bumpLayout()
}) })
ro.observe(canvas.parentElement) ro.observe(canvas.parentElement)
return () => ro.disconnect() return () => ro.disconnect()
}, [draw]) }, [draw])
// Re-read of layoutRef on every render via layoutTick — keeps the SVG // Compute layout fresh on every render — `layoutTick` (bumped from
// overlay in sync with canvas pan/zoom (which live in refs for perf). // wheel / canvas pan / resize) plus prop changes (gcodeConfig) drive
const L = layoutRef.current // re-renders. Layout reads pan/zoom from stateRef and gcodeConfig from
const showOverlay = viewMode === 'gcode' && gcodeConfig && L.mode === 'gcode' && L.spm > 0 // 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 let overlay = null
if (showOverlay) { if (showOverlay) {
void layoutTick // ensure render depends on the tick
const bedW = gcodeConfig.bed_w_mm * L.spm const bedW = gcodeConfig.bed_w_mm * L.spm
const bedH = gcodeConfig.bed_h_mm * L.spm const bedH = gcodeConfig.bed_h_mm * L.spm
const paperW = gcodeConfig.paper_w_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' }, { id: 'br', cx: L.image_x + L.image_w_screen, cy: L.image_y + L.image_h_screen, cur: 'nwse-resize' },
] ]
overlay = ( overlay = (
<svg className="absolute inset-0 w-full h-full" style={{ pointerEvents: 'none' }}> <svg className="absolute inset-0 w-full h-full" style={{ pointerEvents: 'none' }}
onWheel={onWheel}>
{/* Bed — informational only, no drag */} {/* Bed — informational only, no drag */}
<rect x={L.bed_x} y={L.bed_y} width={bedW} height={bedH} <rect x={L.bed_x} y={L.bed_y} width={bedW} height={bedH}
fill="rgba(80,200,220,0.04)" stroke="rgba(80,200,220,0.7)" fill="rgba(80,200,220,0.04)" stroke="rgba(80,200,220,0.7)"