@@ -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 = (
+
+ )
+ }
+
return (
<>
+ {overlay}
![]()