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 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)
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
Reference in New Issue
Block a user