gcode tab: drag handles for paper-on-bed + image-on-paper

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.
This commit is contained in:
Mitchell Hansen
2026-05-08 23:12:00 -07:00
parent 356ddd230d
commit ec36adb8a3
2 changed files with 202 additions and 71 deletions

View File

@@ -384,30 +384,6 @@ export default function App() {
</div>
</div>
{/* Paper on bed */}
<div className="space-y-0.5">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">
Paper on bed ({gcodeConfig.bed_w_mm}×{gcodeConfig.bed_h_mm}mm)
</p>
<Slider label="Paper X" value={gcodeConfig.paper_offset_x_mm ?? 0}
min={0} max={Math.max(0, (gcodeConfig.bed_w_mm ?? 220) - gcodeConfig.paper_w_mm)} step={1} unit="mm"
onChange={v => setGcode({ paper_offset_x_mm: v })} />
<Slider label="Paper Y" value={gcodeConfig.paper_offset_y_mm ?? 0}
min={0} max={Math.max(0, (gcodeConfig.bed_h_mm ?? 320) - gcodeConfig.paper_h_mm)} step={1} unit="mm"
onChange={v => setGcode({ paper_offset_y_mm: v })} />
</div>
{/* Placement */}
<div className="space-y-0.5">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Image on paper</p>
<Slider label="Width mm" value={gcodeConfig.img_w_mm} min={10} max={2000} step={1}
onChange={v => setGcode({ img_w_mm: v })} />
<Slider label="Offset X" value={gcodeConfig.offset_x_mm} min={-500} max={500} step={1} unit="mm"
onChange={v => setGcode({ offset_x_mm: v })} />
<Slider label="Offset Y" value={gcodeConfig.offset_y_mm} min={-500} max={500} step={1} unit="mm"
onChange={v => setGcode({ offset_y_mm: v })} />
</div>
{/* Plotter */}
<div className="space-y-0.5">
<div className="flex items-center gap-2 mb-1.5">
@@ -509,6 +485,7 @@ export default function App() {
imgSize={canvasDims}
viewMode={viewMode}
gcodeConfig={gcodeConfig}
setGcode={setGcode}
/>
)}
{showPerf && <PerfPanel data={perfData} fps={fps} longTasks={longTasks} />}

View File

@@ -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 = (
<svg className="absolute inset-0 w-full h-full" style={{ pointerEvents: 'none' }}>
{/* 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)"
strokeWidth={2} strokeDasharray="10 6" />
<text x={L.bed_x + 4} y={L.bed_y - 5} fill="rgba(80,200,220,0.85)" fontSize={11}>
Bed {gcodeConfig.bed_w_mm.toFixed(0)}×{gcodeConfig.bed_h_mm.toFixed(0)}mm
</text>
{/* Paper — draggable */}
<rect x={L.paper_x} y={L.paper_y} width={paperW} height={paperH}
fill="rgba(255,220,50,0.04)" stroke="rgba(255,220,50,0.85)"
strokeWidth={2} strokeDasharray="6 4"
style={{ pointerEvents: 'auto', cursor: 'move' }}
onMouseDown={e => startOverlayDrag(e, 'paper', {
paper_offset_x_mm: gcodeConfig.paper_offset_x_mm ?? 0,
paper_offset_y_mm: gcodeConfig.paper_offset_y_mm ?? 0,
})} />
<text x={L.paper_x + 4} y={L.paper_y - 5} fill="rgba(255,220,50,0.85)" fontSize={11}
style={{ pointerEvents: 'none', userSelect: 'none' }}>
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)})
</text>
{/* Image — draggable */}
<rect x={L.image_x} y={L.image_y} width={L.image_w_screen} height={L.image_h_screen}
fill="rgba(99,102,241,0.04)" stroke="rgba(165,180,252,0.9)"
strokeWidth={2} strokeDasharray="4 3"
style={{ pointerEvents: 'auto', cursor: 'move' }}
onMouseDown={e => startOverlayDrag(e, 'image', {
offset_x_mm: gcodeConfig.offset_x_mm ?? 0,
offset_y_mm: gcodeConfig.offset_y_mm ?? 0,
})} />
<text x={L.image_x + 4} y={L.image_y - 5} fill="rgba(165,180,252,0.95)" fontSize={11}
style={{ pointerEvents: 'none', userSelect: 'none' }}>
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)})
</text>
{/* Image corner handles — uniform scale (preserves aspect) */}
{cornerCfg.map(c => (
<rect key={c.id}
x={c.cx - HANDLE_PX / 2} y={c.cy - HANDLE_PX / 2}
width={HANDLE_PX} height={HANDLE_PX}
fill="#6366f1" stroke="#fff" strokeWidth={1.5}
style={{ pointerEvents: 'auto', cursor: c.cur }}
onMouseDown={e => 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,
})} />
))}
</svg>
)
}
return (
<>
<canvas
@@ -298,6 +451,7 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
onMouseLeave={onMouseUp}
style={{ cursor: 'crosshair' }}
/>
{overlay}
<img
ref={svgImgRef}
className="absolute pointer-events-none"