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:
@@ -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)
|
||||
|
||||
@@ -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 = (
|
||||
<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 */}
|
||||
<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)"
|
||||
|
||||
Reference in New Issue
Block a user