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}