From ec36adb8a359929b3678ad9f5228461157a46b14 Mon Sep 17 00:00:00 2001 From: Mitchell Hansen Date: Fri, 8 May 2026 23:12:00 -0700 Subject: [PATCH] gcode tab: drag handles for paper-on-bed + image-on-paper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gcode preview is now interactive. The bed sits centred at a fixed scale; paper and image rectangles position relative to it via the existing gcodeConfig offsets and become draggable directly in the preview. Corner handles on the image rect uniformly scale the output (driving img_w_mm; height auto-derives from the pixel-aspect ratio). Layout switch (gcode mode only): bed-anchored instead of image- centered. The previous canvas centred on the strokes, so paper drag made the bed visually shift on screen — confusing. New layout pins the bed to the canvas centre at a fit-to-window scale; paper, image, and strokes all position via mm offsets relative to the bed. Source/pipeline modes still use the original image-anchored layout. SVG overlay sits over the canvas with `pointer-events: none` by default. The paper rect, image rect, and four corner handles take `pointer-events: auto` so clicking on them captures the mousedown before it reaches the canvas — canvas pan stays disabled for the duration of the drag without explicit gating. Empty space falls through to canvas pan as before. Drag handlers attach window-level mousemove/mouseup listeners and commit deltas in mm to gcodeConfig via setGcode each tick. Corner math: each corner anchors the opposite side, so dragging TL grows the image toward TL while keeping the BR corner fixed; image height is height = (ih / iw) * img_w_mm and updates automatically. Removed the canvas-drawn `drawPaperOutline` (the SVG overlay replaces it) and the four sidebar offset/size sliders (Paper X/Y, Width mm, Offset X/Y) — handles-only UI per user request. Verified: npm run build clean. --- src-frontend/src/App.jsx | 25 +-- src-frontend/src/components/Viewport.jsx | 248 ++++++++++++++++++----- 2 files changed, 202 insertions(+), 71 deletions(-) diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index ee3eb8d8..6f1d6b61 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -384,30 +384,6 @@ export default function App() { - {/* Paper on bed */} -
-

- Paper on bed ({gcodeConfig.bed_w_mm}×{gcodeConfig.bed_h_mm}mm) -

- setGcode({ paper_offset_x_mm: v })} /> - setGcode({ paper_offset_y_mm: v })} /> -
- - {/* Placement */} -
-

Image on paper

- setGcode({ img_w_mm: v })} /> - setGcode({ offset_x_mm: v })} /> - setGcode({ offset_y_mm: v })} /> -
- {/* Plotter */}
@@ -509,6 +485,7 @@ export default function App() { imgSize={canvasDims} viewMode={viewMode} gcodeConfig={gcodeConfig} + setGcode={setGcode} /> )} {showPerf && } diff --git a/src-frontend/src/components/Viewport.jsx b/src-frontend/src/components/Viewport.jsx index c27560f1..25d0a677 100644 --- a/src-frontend/src/components/Viewport.jsx +++ b/src-frontend/src/components/Viewport.jsx @@ -1,34 +1,85 @@ -import { useRef, useEffect, useCallback } from 'react' +import { useRef, useEffect, useCallback, useState } from 'react' // Strokes per requestAnimationFrame chunk. // Each chunk draws this many strokes then yields back to the browser. const CHUNK_SIZE = 300 -export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeConfig }) { +// Pixel size of the corner-resize handles drawn over the image rect. +const HANDLE_PX = 10 + +export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeConfig, setGcode }) { const canvasRef = useRef(null) 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. + const [layoutTick, setLayoutTick] = useState(0) + const layoutRef = useRef({ 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 } + // 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 const chunkRef = useRef({ flat: null, idx: 0, raf: null }) // ── Compute layout helpers ──────────────────────────────────────────────── + // Two layout modes: + // • gcode mode: bed-anchored. The bed sits centred in the canvas at a + // scale that fits it, and paper/image rectangles position relative + // to the bed via gcodeConfig offsets. Dragging paper or image moves + // them within the bed, while the bed itself stays put on screen — + // the natural mental model for plotter layout. + // • everything else: image-anchored. The strokes/image fill the + // viewport regardless of mm-coords, since there's no plotter + // context in source/pipeline modes. function layout(canvas) { const { zoom, pan } = stateRef.current const W = canvas.width const H = canvas.height - // gcode/fill views use the scaled pipeline dimensions from strokes payload; - // all other views use the original loaded image dimensions. 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) + + if (viewMode === 'gcode' && gcodeConfig && gcodeConfig.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 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 + // 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 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 { + mode: 'gcode', + iw, ih, spm, + bed_x, bed_y, + paper_x, paper_y, + image_x, image_y, image_w_screen, image_h_screen, + // Aliases used by the strokes-canvas drawImage path: + ox: image_x, oy: image_y, scale: stroke_scale, + } + } + + // Image-anchored layout (source / pipeline / fill etc.) const fit = Math.min(W / iw, H / ih) * 0.92 const scale = fit * zoom const ox = W / 2 - iw * scale / 2 + pan.x const oy = H / 2 - ih * scale / 2 + pan.y - return { iw, ih, scale, ox, oy } + return { mode: 'image', iw, ih, scale, ox, oy, spm: 0 } } // ── Main canvas draw — just composites whatever is in the offscreen ─────── @@ -36,7 +87,11 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') - const { iw, ih, scale, ox, oy } = layout(canvas) + 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' @@ -57,6 +112,8 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo } } else if (viewMode === 'gcode') { if (svgImg) svgImg.style.display = 'none' + // Bed/paper/image rectangles are drawn by the SVG overlay below; + // canvas only renders the strokes inside the image rect. if (imgSize) { ctx.fillStyle = '#f5f0e8' ctx.fillRect(ox, oy, iw * scale, ih * scale) @@ -65,7 +122,6 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo if (off && imgSize) { ctx.drawImage(off, ox, oy, iw * scale, ih * scale) } - drawPaperOutline(ctx, iw, ih, scale, ox, oy) } else { // All raster views (source=JPEG, detection=JPEG, contours=SVG) go through ctx.drawImage // so that negative ox/oy when zoomed in are handled correctly by the canvas, @@ -93,46 +149,6 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo ctx.fillRect(ox, oy, iw * scale, ih * scale) } } - drawPaperOutline(ctx, iw, ih, scale, ox, oy) - } - - function drawPaperOutline(ctx, iw, ih, scale, ox, oy) { - if (!gcodeConfig || !imgSize) return - const imgWmm = gcodeConfig.img_w_mm - if (!imgWmm || imgWmm <= 0) return - const spm = (iw * scale) / imgWmm - const paperOffX = gcodeConfig.paper_offset_x_mm ?? 0 - const paperOffY = gcodeConfig.paper_offset_y_mm ?? 0 - const px = ox - gcodeConfig.offset_x_mm * spm - const py = oy - gcodeConfig.offset_y_mm * spm - const pw = gcodeConfig.paper_w_mm * spm - const ph = gcodeConfig.paper_h_mm * spm - ctx.save() - // Bed outline (outermost, cyan) - if (gcodeConfig.bed_w_mm && gcodeConfig.bed_h_mm) { - const bx = px - paperOffX * spm - const by = py - paperOffY * spm - const bw = gcodeConfig.bed_w_mm * spm - const bh = gcodeConfig.bed_h_mm * spm - ctx.strokeStyle = 'rgba(80, 200, 220, 0.8)' - ctx.lineWidth = 2 - ctx.setLineDash([10, 6]) - ctx.strokeRect(bx, by, bw, bh) - ctx.setLineDash([]) - ctx.fillStyle = 'rgba(80, 200, 220, 0.7)' - ctx.font = `${Math.max(10, spm * 5)}px sans-serif` - ctx.fillText(`Bed ${gcodeConfig.bed_w_mm}×${gcodeConfig.bed_h_mm}mm`, bx + 4, by - 4) - } - // Paper outline (yellow dashed) - ctx.strokeStyle = 'rgba(255, 220, 50, 0.8)' - ctx.lineWidth = 2 - ctx.setLineDash([6, 4]) - ctx.strokeRect(px, py, pw, ph) - ctx.setLineDash([]) - ctx.fillStyle = 'rgba(255, 220, 50, 0.7)' - ctx.font = `${Math.max(10, spm * 5)}px sans-serif` - ctx.fillText(`${gcodeConfig.paper_w_mm}×${gcodeConfig.paper_h_mm}mm`, px + 4, py - 4) - ctx.restore() } }, [imageB64, strokes, imgSize, viewMode, gcodeConfig]) @@ -274,6 +290,67 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo } function onMouseUp() { stateRef.current.dragging = false } + // ── Gcode-overlay drag (paper / image / corner handles) ────────────────── + // Each drag installs window-level mousemove + mouseup listeners that + // commit deltas (in mm) to gcodeConfig via setGcode. Handle rects in the + // SVG overlay set pointer-events: auto so they capture the click before + // it reaches the canvas (canvas pan stays disabled for the drag). + function startOverlayDrag(e, kind, startVals) { + if (!setGcode) return + e.preventDefault() + e.stopPropagation() + const startX = e.clientX + const startY = e.clientY + const layoutAtStart = layoutRef.current + const aspect = layoutAtStart.iw > 0 ? layoutAtStart.ih / layoutAtStart.iw : 1 + + function move(ev) { + const spm = layoutRef.current.spm + if (spm <= 0) return + const dx_mm = (ev.clientX - startX) / spm + const dy_mm = (ev.clientY - startY) / spm + if (kind === 'paper') { + setGcode({ + paper_offset_x_mm: startVals.paper_offset_x_mm + dx_mm, + paper_offset_y_mm: startVals.paper_offset_y_mm + dy_mm, + }) + } else if (kind === 'image') { + setGcode({ + offset_x_mm: startVals.offset_x_mm + dx_mm, + offset_y_mm: startVals.offset_y_mm + dy_mm, + }) + } else if (kind.startsWith('corner-')) { + const corner = kind.slice(7) // 'tl' | 'tr' | 'bl' | 'br' + // signX: +1 if dragging this corner right grows the image, -1 otherwise. + const signX = (corner === 'tr' || corner === 'br') ? 1 : -1 + const new_w = Math.max(1, startVals.img_w_mm + signX * dx_mm) + const new_h = new_w * aspect + const old_h = startVals.img_w_mm * aspect + // Anchor the opposite corner: when the LEFT side moves, + // offset_x compensates so the right edge stays put. Same for top. + let new_offset_x = startVals.offset_x_mm + let new_offset_y = startVals.offset_y_mm + if (corner === 'tl' || corner === 'bl') { + new_offset_x = startVals.offset_x_mm + (startVals.img_w_mm - new_w) + } + if (corner === 'tl' || corner === 'tr') { + new_offset_y = startVals.offset_y_mm + (old_h - new_h) + } + setGcode({ + img_w_mm: new_w, + offset_x_mm: new_offset_x, + offset_y_mm: new_offset_y, + }) + } + } + function end() { + window.removeEventListener('mousemove', move) + window.removeEventListener('mouseup', end) + } + window.addEventListener('mousemove', move) + window.addEventListener('mouseup', end) + } + useEffect(() => { const canvas = canvasRef.current const ro = new ResizeObserver(() => { @@ -286,6 +363,82 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo 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 + 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 + const paperH = gcodeConfig.paper_h_mm * L.spm + const cornerCfg = [ + { id: 'tl', cx: L.image_x, cy: L.image_y, cur: 'nwse-resize' }, + { id: 'tr', cx: L.image_x + L.image_w_screen, cy: L.image_y, cur: 'nesw-resize' }, + { id: 'bl', cx: L.image_x, cy: L.image_y + L.image_h_screen, cur: 'nesw-resize' }, + { 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 */} + + + Bed {gcodeConfig.bed_w_mm.toFixed(0)}×{gcodeConfig.bed_h_mm.toFixed(0)}mm + + + {/* Paper — draggable */} + startOverlayDrag(e, 'paper', { + paper_offset_x_mm: gcodeConfig.paper_offset_x_mm ?? 0, + paper_offset_y_mm: gcodeConfig.paper_offset_y_mm ?? 0, + })} /> + + Paper {gcodeConfig.paper_w_mm.toFixed(0)}×{gcodeConfig.paper_h_mm.toFixed(0)}mm + @ ({(gcodeConfig.paper_offset_x_mm ?? 0).toFixed(1)}, + {(gcodeConfig.paper_offset_y_mm ?? 0).toFixed(1)}) + + + {/* Image — draggable */} + startOverlayDrag(e, 'image', { + offset_x_mm: gcodeConfig.offset_x_mm ?? 0, + offset_y_mm: gcodeConfig.offset_y_mm ?? 0, + })} /> + + Image {(gcodeConfig.img_w_mm ?? 0).toFixed(1)}×{((L.iw > 0 ? (L.ih / L.iw) * (gcodeConfig.img_w_mm ?? 0) : 0)).toFixed(1)}mm + @ ({(gcodeConfig.offset_x_mm ?? 0).toFixed(1)}, + {(gcodeConfig.offset_y_mm ?? 0).toFixed(1)}) + + + {/* Image corner handles — uniform scale (preserves aspect) */} + {cornerCfg.map(c => ( + startOverlayDrag(e, 'corner-' + c.id, { + img_w_mm: gcodeConfig.img_w_mm ?? 0, + offset_x_mm: gcodeConfig.offset_x_mm ?? 0, + offset_y_mm: gcodeConfig.offset_y_mm ?? 0, + })} /> + ))} + + ) + } + return ( <> + {overlay}