From ef0a09126aca3a94fdefe40e9b1ca3a8a6aa826d Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Wed, 10 Jun 2026 13:53:03 -0400 Subject: [PATCH 01/11] fix: Commit slider number inputs on blur/Enter instead of clamping per keystroke Typing a value whose first digits fell below the field minimum (e.g. 37 into the 30-100 grid unit field) was clamped mid-typing and ended up at the maximum. New shared NumberField keeps local text state and only parses/clamps on commit, mirroring the existing DepthInput pattern. Co-Authored-By: Claude Fable 5 --- frontend/src/components/BinConfigurator.tsx | 10 +- frontend/src/components/NumberField.tsx | 101 ++++++++++++++++++++ frontend/src/components/SettingsPopover.tsx | 10 +- 3 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/NumberField.tsx diff --git a/frontend/src/components/BinConfigurator.tsx b/frontend/src/components/BinConfigurator.tsx index 2ac2bca..b88a919 100644 --- a/frontend/src/components/BinConfigurator.tsx +++ b/frontend/src/components/BinConfigurator.tsx @@ -3,6 +3,7 @@ import { Info, Link2, Link2Off } from 'lucide-react' import type { BinConfig } from '@/types' import { SNAP_FRACTIONS, type SnapMode } from '@/lib/constants' +import { NumberField } from '@/components/NumberField' const GF_HEIGHT_UNIT = 7.0 const GF_BASE_HEIGHT = 4.75 @@ -111,18 +112,13 @@ function SliderRow({ style={{ '--slider-pct': `${pct}%` } as React.CSSProperties} />
- { - const v = step >= 1 ? parseInt(e.target.value) : parseFloat(e.target.value) - if (!isNaN(v)) onChange(Math.min(max, Math.max(min, v))) - }} - className="w-14 h-7 bg-elevated text-right text-xs font-semibold text-text-primary rounded pr-2 focus:outline-none" + onCommit={onChange} /> {unit && {unit}}
diff --git a/frontend/src/components/NumberField.tsx b/frontend/src/components/NumberField.tsx new file mode 100644 index 0000000..7fe6b1a --- /dev/null +++ b/frontend/src/components/NumberField.tsx @@ -0,0 +1,101 @@ +'use client' + +import { useEffect, useState } from 'react' + +interface NumberFieldProps { + value: number | null + min: number + max: number + step?: number + onCommit: (v: number) => void + onCommitNull?: () => void + nullable?: boolean + placeholder?: string + disabled?: boolean + className?: string +} + +function stepDecimals(step: number): number { + return step >= 1 ? 0 : Math.min(3, String(step).split('.')[1]?.length ?? 1) +} + +function format(value: number | null, step: number): string { + if (value == null) return '' + // avoid float noise like 0.30000000000000004 from step arithmetic + return String(Number(value.toFixed(stepDecimals(step)))) +} + +/** + * Numeric input that only parses/clamps on commit (blur or Enter), never per + * keystroke -- typing "37" into a min-30 field must not clamp the intermediate + * "3" to 30. Escape reverts to the last committed value. + */ +export function NumberField({ + value, + min, + max, + step = 1, + onCommit, + onCommitNull, + nullable, + placeholder, + disabled, + className, +}: NumberFieldProps) { + const [text, setText] = useState(() => format(value, step)) + const [focused, setFocused] = useState(false) + + // follow external changes (e.g. paired slider drags) while not being typed in + useEffect(() => { + if (!focused) setText(format(value, step)) + }, [value, step, focused]) + + const revert = () => setText(format(value, step)) + + const commit = (raw: string) => { + const trimmed = raw.trim() + if (trimmed === '' && nullable) { + onCommitNull?.() + return + } + const n = parseFloat(trimmed) + if (isNaN(n)) { + revert() + return + } + const clamped = Math.min(max, Math.max(min, n)) + const snapped = Math.round((clamped - min) / step) * step + min + const final = Number(Math.min(max, snapped).toFixed(stepDecimals(step))) + setText(format(final, step)) + onCommit(final) + } + + return ( + setFocused(true)} + onChange={(e) => setText(e.target.value)} + onBlur={(e) => { + setFocused(false) + commit(e.target.value) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur() + if (e.key === 'Escape') { + revert() + ;(e.currentTarget as HTMLInputElement).blur() + } + }} + className={ + className ?? + 'w-14 h-7 bg-elevated text-right text-xs font-semibold text-text-primary rounded pr-2 focus:outline-none' + } + /> + ) +} diff --git a/frontend/src/components/SettingsPopover.tsx b/frontend/src/components/SettingsPopover.tsx index 29f70d0..e852667 100644 --- a/frontend/src/components/SettingsPopover.tsx +++ b/frontend/src/components/SettingsPopover.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react' import { Settings } from 'lucide-react' import { getSettings, saveSettings } from '@/lib/settings' import { IconButton } from '@/components/IconButton' +import { NumberField } from '@/components/NumberField' export function SettingsPopover() { const [open, setOpen] = useState(false) @@ -58,17 +59,12 @@ export function SettingsPopover() { style={{ '--slider-pct': `${pct}%` } as React.CSSProperties} />
- { - const v = parseInt(e.target.value) - if (!isNaN(v)) handleBedSizeChange(Math.min(400, Math.max(150, v))) - }} - className="w-14 h-7 bg-elevated text-right text-xs font-semibold text-text-primary rounded pr-2 focus:outline-none" + onCommit={handleBedSizeChange} /> mm
From 2042bff3f085820e95d7b8fe62eb9c8db6649bef Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Wed, 10 Jun 2026 13:53:14 -0400 Subject: [PATCH 02/11] feat: Add measurement toggle showing edge lengths and corner angles Shared MeasurementOverlay renders mm edge lengths along each edge and interior angles at each vertex, with decimation for dense traces. Wired into the trace-page PolygonEditor (via a new scaleFactor prop fed from session.scale_factor) and the library ToolEditor (mm-native, includes interior rings). Co-Authored-By: Claude Fable 5 --- frontend/src/app/trace/[id]/page.tsx | 3 + .../src/components/MeasurementOverlay.tsx | 120 ++++++++++++++++++ frontend/src/components/PolygonEditor.tsx | 33 ++++- frontend/src/components/ToolEditor.tsx | 4 + frontend/src/components/ToolEditorCanvas.tsx | 13 +- frontend/src/components/ToolEditorToolbar.tsx | 15 ++- frontend/src/lib/svg.ts | 44 +++++++ 7 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/MeasurementOverlay.tsx diff --git a/frontend/src/app/trace/[id]/page.tsx b/frontend/src/app/trace/[id]/page.tsx index d2c0513..869b5d0 100644 --- a/frontend/src/app/trace/[id]/page.tsx +++ b/frontend/src/app/trace/[id]/page.tsx @@ -141,6 +141,8 @@ export default function TracePage() { const result = await setCorners(sessionId, corners, paperSize) setCorrectedImageUrl(result.corrected_image_url) setImageVersion(Date.now()) + // keep scale_factor current so the measurement overlay works without a reload + setSession((s) => (s ? { ...s, scale_factor: result.scale_factor } : s)) if (singleTracer && tracers.length === 1) { // single tracer: trace immediately without changing step @@ -671,6 +673,7 @@ export default function TracePage() { onIncludedChange={step === 'edit' ? setIncludedPolygons : undefined} hovered={step === 'edit' ? hoveredPolygon : undefined} onHoveredChange={step === 'edit' ? setHoveredPolygon : undefined} + scaleFactor={session?.scale_factor} /> )} diff --git a/frontend/src/components/MeasurementOverlay.tsx b/frontend/src/components/MeasurementOverlay.tsx new file mode 100644 index 0000000..cfa9e75 --- /dev/null +++ b/frontend/src/components/MeasurementOverlay.tsx @@ -0,0 +1,120 @@ +'use client' + +import type { Point } from '@/types' +import { signedArea, edgeMidpointNormal, interiorAngleDeg, interiorBisector } from '@/lib/svg' + +interface Props { + points: Point[] + holes?: Point[][] + /** multiply SVG-unit distances by this to get mm */ + mmPerUnit: number + /** scales fonts/offsets so labels stay readable at any canvas size */ + uiScale: number +} + +const MAX_EDGE_LABELS = 120 + +function formatMm(mm: number): string { + return mm >= 10 ? mm.toFixed(1) : mm.toFixed(2) +} + +function RingMeasurements({ points, mmPerUnit, uiScale }: { points: Point[]; mmPerUnit: number; uiScale: number }) { + if (points.length < 3) return null + + const ccw = signedArea(points) > 0 + const n = points.length + const minEdgeLen = 24 * uiScale + + const edges = points.map((p, i) => { + const q = points[(i + 1) % n] + const len = Math.hypot(q.x - p.x, q.y - p.y) + return { i, p, q, len } + }) + + const shown = new Set( + edges + .filter((e) => e.len >= minEdgeLen) + .sort((a, b) => b.len - a.len) + .slice(0, MAX_EDGE_LABELS) + .map((e) => e.i) + ) + + const fontSize = 11 * uiScale + const halo: React.CSSProperties = { + paintOrder: 'stroke', + stroke: 'rgba(24, 24, 27, 0.85)', + strokeWidth: 3 * uiScale, + strokeLinejoin: 'round', + } + + return ( + + {edges.map((e) => { + if (!shown.has(e.i)) return null + const { mid, normal } = edgeMidpointNormal(e.p, e.q, ccw) + const x = mid.x + normal.x * 10 * uiScale + const y = mid.y + normal.y * 10 * uiScale + let deg = (Math.atan2(e.q.y - e.p.y, e.q.x - e.p.x) * 180) / Math.PI + if (deg > 90) deg -= 180 + if (deg < -90) deg += 180 + return ( + + {formatMm(e.len * mmPerUnit)} + + ) + })} + {points.map((v, i) => { + // angle label only where both adjacent edges are labeled, to limit clutter + const prevEdge = (i - 1 + n) % n + if (!shown.has(prevEdge) || !shown.has(i)) return null + const prev = points[prevEdge] + const next = points[(i + 1) % n] + const angle = interiorAngleDeg(prev, v, next, ccw) + if (angle > 178 && angle < 182) return null // collinear trace noise + const bis = interiorBisector(prev, v, next, ccw) + const x = v.x + bis.x * 14 * uiScale + const y = v.y + bis.y * 14 * uiScale + return ( + + {Math.round(angle)}° + + ) + })} + + ) +} + +/** + * SVG overlay showing edge lengths (mm) and interior vertex angles for a + * polygon ring and its holes. Coordinate-system agnostic: the host passes + * points in its own SVG units plus the unit->mm factor. + */ +export function MeasurementOverlay({ points, holes, mmPerUnit, uiScale }: Props) { + return ( + <> + + {(holes ?? []).map((hole, i) => ( + + ))} + + ) +} diff --git a/frontend/src/components/PolygonEditor.tsx b/frontend/src/components/PolygonEditor.tsx index 2813175..b89e3b6 100644 --- a/frontend/src/components/PolygonEditor.tsx +++ b/frontend/src/components/PolygonEditor.tsx @@ -2,9 +2,10 @@ import { useState, useRef, useEffect, useCallback } from 'react' import type { Point, Polygon } from '@/types' -import { Undo2, Redo2, Trash2, Plus, Minus, Move } from 'lucide-react' +import { Undo2, Redo2, Trash2, Plus, Minus, Move, Ruler } from 'lucide-react' import { polygonPathData } from '@/lib/svg' import { useHistory } from '@/hooks/useHistory' +import { MeasurementOverlay } from '@/components/MeasurementOverlay' interface Props { imageUrl: string @@ -15,6 +16,8 @@ interface Props { onIncludedChange?: (ids: Set) => void hovered?: string | null onHoveredChange?: (id: string | null) => void + /** mm per image px (session.scale_factor); enables the measurement toggle */ + scaleFactor?: number | null } // base sizes for SVG UI elements, designed for ~800px viewBox width const BASE_VIEW_WIDTH = 800 @@ -33,6 +36,7 @@ export function PolygonEditor({ onIncludedChange, hovered, onHoveredChange, + scaleFactor, }: Props) { const wrapperRef = useRef(null) const containerRef = useRef(null) @@ -54,6 +58,7 @@ export function PolygonEditor({ const [editMode, setEditMode] = useState('select') const [dragging, setDragging] = useState(null) + const [showMeasurements, setShowMeasurements] = useState(false) const { set: pushHistory, undo: handleUndo, redo: handleRedo, canUndo, canRedo } = useHistory( polygons, @@ -353,6 +358,23 @@ export function PolygonEditor({ + {scaleFactor != null && ( + <> +
+ + + )} + {(editMode === 'select' || editMode === 'vertex') && !activeId && 'Click outlines to select tools'} {(editMode === 'select' || editMode === 'vertex') && activeId && 'Drag vertices to adjust the outline'} @@ -487,6 +509,15 @@ export function PolygonEditor({ ))} + {isActive && showMeasurements && scaleFactor != null && ( + + )} + ) })} diff --git a/frontend/src/components/ToolEditor.tsx b/frontend/src/components/ToolEditor.tsx index bab7593..89d5347 100644 --- a/frontend/src/components/ToolEditor.tsx +++ b/frontend/src/components/ToolEditor.tsx @@ -46,6 +46,7 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot const [editMode, setEditMode] = useState('select') const [dragging, setDragging] = useState(null) const [snapEnabled, setSnapEnabled] = useState(true) + const [showMeasurements, setShowMeasurements] = useState(false) const [zoom, setZoom] = useState(1) const [pan, setPan] = useState({ x: 0, y: 0 }) const [cutoutOpen, setCutoutOpen] = useState(false) @@ -589,6 +590,7 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot displayPoints={displayPoints} smoothed={smoothed} interiorRings={interiorRings} + showMeasurements={showMeasurements} points={points} editMode={editMode} selection={selection} @@ -613,6 +615,8 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot onSmoothLevelChange={onSmoothLevelChange} snapEnabled={snapEnabled} setSnapEnabled={setSnapEnabled} + showMeasurements={showMeasurements} + setShowMeasurements={setShowMeasurements} canUndo={canUndo} canRedo={canRedo} handleUndo={handleUndo} diff --git a/frontend/src/components/ToolEditorCanvas.tsx b/frontend/src/components/ToolEditorCanvas.tsx index 7db43c1..d739e54 100644 --- a/frontend/src/components/ToolEditorCanvas.tsx +++ b/frontend/src/components/ToolEditorCanvas.tsx @@ -5,6 +5,7 @@ import type { Point, FingerHole } from '@/types' import { polygonPathData, smoothPathData } from '@/lib/svg' import { DISPLAY_SCALE } from '@/lib/constants' import { CutoutOverlay } from '@/components/CutoutOverlay' +import { MeasurementOverlay } from '@/components/MeasurementOverlay' import type { EditMode, Selection } from '@/components/ToolEditorToolbar' interface Props { @@ -29,6 +30,7 @@ interface Props { displayPoints: Point[] smoothed: boolean interiorRings?: Point[][] + showMeasurements?: boolean // edge/vertex interactions points: Point[] @@ -51,7 +53,7 @@ export function ToolEditorCanvas({ svgRef, zvbX, zvbY, zvbW, zvbH, isCutoutMode, handleBackgroundClick, handleSvgMouseDown, gridMinX, gridMaxX, gridMinY, gridMaxY, gridStep, zoom, - displayPoints, smoothed, interiorRings, + displayPoints, smoothed, interiorRings, showMeasurements, points, editMode, selection, handleEdgeClick, handleVertexMouseDown, displayHoles, handleHoleMouseDown, handleResizeMouseDown, handleHoleRotateMouseDown, @@ -113,6 +115,15 @@ export function ToolEditorCanvas({ strokeWidth={2 / zoom} /> + {showMeasurements && ( + ({ x: p.x * DISPLAY_SCALE, y: p.y * DISPLAY_SCALE }))} + holes={interiorRings?.map(ring => ring.map(p => ({ x: p.x * DISPLAY_SCALE, y: p.y * DISPLAY_SCALE })))} + mmPerUnit={1 / DISPLAY_SCALE} + uiScale={zvbW / 800} + /> + )} + {/* per-ring hit areas for fill-ring mode */} {editMode === 'fill-ring' && interiorRings?.map((ring, idx) => { const d = ring.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x * DISPLAY_SCALE} ${p.y * DISPLAY_SCALE}`).join(' ') + ' Z' diff --git a/frontend/src/components/ToolEditorToolbar.tsx b/frontend/src/components/ToolEditorToolbar.tsx index c24fa06..19972f1 100644 --- a/frontend/src/components/ToolEditorToolbar.tsx +++ b/frontend/src/components/ToolEditorToolbar.tsx @@ -1,7 +1,7 @@ 'use client' import { ReactNode } from 'react' -import { MousePointer2, Plus, Minus, Undo2, Redo2, Trash2, Circle, Disc, Square, RectangleHorizontal, Fingerprint, Magnet, RotateCw, RotateCcw, FlipHorizontal2, FlipVertical2, ChevronDown, PaintBucket } from 'lucide-react' +import { MousePointer2, Plus, Minus, Undo2, Redo2, Trash2, Circle, Disc, Square, RectangleHorizontal, Fingerprint, Magnet, RotateCw, RotateCcw, FlipHorizontal2, FlipVertical2, ChevronDown, PaintBucket, Ruler } from 'lucide-react' import type { FingerHole } from '@/types' import { SNAP_GRID } from '@/lib/constants' @@ -21,6 +21,8 @@ interface Props { onSmoothLevelChange: (level: number) => void snapEnabled: boolean setSnapEnabled: (enabled: boolean) => void + showMeasurements: boolean + setShowMeasurements: (show: boolean) => void canUndo: boolean canRedo: boolean handleUndo: () => void @@ -43,6 +45,7 @@ export function ToolEditorToolbar({ editMode, setEditMode, smoothed, smoothLevel, onSmoothedChange, onSmoothLevelChange, snapEnabled, setSnapEnabled, + showMeasurements, setShowMeasurements, canUndo, canRedo, handleUndo, handleRedo, cutoutOpen, setCutoutOpen, isCutoutMode, cutoutModeIcon, cutoutModeLabel, @@ -184,6 +187,16 @@ export function ToolEditorToolbar({ Snap +
+
{filteredTools.map(tool => (
(null) const [name, setName] = useState('') + const [materializeError, setMaterializeError] = useState(null) + useEffect(() => { async function load() { try { @@ -37,10 +39,50 @@ export default function ToolPage() { const { saving, saved } = useDebouncedSave( async () => { if (!tool) return - await updateTool(toolId, { name, points: tool.points, finger_holes: tool.finger_holes, interior_rings: tool.interior_rings, smoothed: tool.smoothed, smooth_level: tool.smooth_level }) + if (tool.shapes != null) { + const sent = tool.shapes + try { + const ret = await updateTool(toolId, { + name, + shapes: sent, + clearance_override: tool.clearance_override ?? null, + }) + setMaterializeError(null) + // apply the authoritative materialized outline; only adopt the + // recentred shapes when no newer local edit is pending + setTool((prev) => { + if (!prev || prev.shapes == null) return prev + const sameShapes = JSON.stringify(prev.shapes) === JSON.stringify(sent) + const nextShapes = sameShapes ? (ret.shapes ?? prev.shapes) : prev.shapes + if ( + JSON.stringify(prev.points) === JSON.stringify(ret.points) && + JSON.stringify(prev.interior_rings) === JSON.stringify(ret.interior_rings) && + JSON.stringify(prev.shapes) === JSON.stringify(nextShapes) + ) { + return prev + } + return { ...prev, points: ret.points, interior_rings: ret.interior_rings, shapes: nextShapes } + }) + } catch (err) { + if (err instanceof ApiError && err.status === 422) { + setMaterializeError(err.message) + return + } + throw err + } + } else { + await updateTool(toolId, { + name, + points: tool.points, + finger_holes: tool.finger_holes, + interior_rings: tool.interior_rings, + smoothed: tool.smoothed, + smooth_level: tool.smooth_level, + }) + } }, [tool, name, toolId], - 150, + 300, { skipInitial: true } ) @@ -64,6 +106,25 @@ export default function ToolPage() { setTool(prev => prev ? { ...prev, interior_rings } : null) }, []) + const handleShapesChange = useCallback((shapes: ToolShape[]) => { + setTool(prev => prev ? { ...prev, shapes } : null) + }, []) + + const handleClearanceChange = useCallback((clearance_override: number | null) => { + setTool(prev => prev ? { ...prev, clearance_override } : null) + }, []) + + const handleConvertToPolygon = useCallback(async () => { + if (!window.confirm('Convert to a freeform polygon? The shape parameters are discarded and this cannot be undone.')) return + try { + const ret = await updateTool(toolId, { shapes: null }) + setMaterializeError(null) + setTool(prev => prev ? { ...prev, shapes: null, points: ret.points, interior_rings: ret.interior_rings } : prev) + } catch { + // keep the designer open; the next autosave will surface errors + } + }, [toolId]) + if (loading) { return (
@@ -81,6 +142,8 @@ export default function ToolPage() { ) } + const isParametric = tool.shapes != null + return (
{/* floating breadcrumb panel */} @@ -105,18 +168,31 @@ export default function ToolPage() {
{/* editor fills the entire area */} - + {isParametric ? ( + + ) : ( + + )}
) } diff --git a/frontend/src/components/ShapeDesigner.tsx b/frontend/src/components/ShapeDesigner.tsx new file mode 100644 index 0000000..1bb6397 --- /dev/null +++ b/frontend/src/components/ShapeDesigner.tsx @@ -0,0 +1,393 @@ +'use client' + +import { useState, useRef, useCallback, useEffect } from 'react' +import { Undo2, Redo2, Magnet } from 'lucide-react' +import type { Point, ToolShape } from '@/types' +import { DISPLAY_SCALE, ZOOM_FACTOR } from '@/lib/constants' +import { rotatePoint, shapeBounds } from '@/lib/shapes' +import { snapShapePosition, snapRotation, type SnapIndicator } from '@/lib/shapeSnap' +import { useHistory } from '@/hooks/useHistory' +import { ShapeDesignerCanvas } from '@/components/ShapeDesignerCanvas' +import { ShapeListPanel } from '@/components/ShapeListPanel' + +const PADDING_MM = 20 +const GRID_OPTIONS = [ + { label: 'Off', value: 0 }, + { label: '0.1', value: 0.1 }, + { label: '0.5', value: 0.5 }, + { label: '1', value: 1 }, + { label: '5', value: 5 }, +] + +interface Props { + shapes: ToolShape[] + outlinePoints: Point[] + outlineRings: Point[][] + clearanceOverride: number | null + materializeError: string | null + onShapesChange: (shapes: ToolShape[]) => void + onClearanceChange: (v: number | null) => void + onConvertToPolygon: () => void +} + +type DragState = + | { type: 'shape'; id: string; startMm: Point; orig: ToolShape; alt: boolean } + | { type: 'resize'; id: string; orig: ToolShape } + | { type: 'rotate'; id: string; orig: ToolShape; startAngle: number; alt: boolean } + | { type: 'pan'; startClientX: number; startClientY: number; origPanX: number; origPanY: number; svgScale: number } + | null + +export function ShapeDesigner({ + shapes, + outlinePoints, + outlineRings, + clearanceOverride, + materializeError, + onShapesChange, + onClearanceChange, + onConvertToPolygon, +}: Props) { + const svgRef = useRef(null) + const [selectedId, setSelectedId] = useState(null) + const [gridMm, setGridMm] = useState(1) + const [zoom, setZoom] = useState(1) + const [pan, setPan] = useState({ x: 0, y: 0 }) + const [dragging, setDragging] = useState(null) + const [dragShapes, setDragShapes] = useState(null) + const [snapIndicator, setSnapIndicator] = useState(null) + const spaceHeld = useRef(false) + const didPanRef = useRef(false) + + const displayShapes = dragShapes ?? shapes + + const { set: pushHistory, undo: handleUndo, redo: handleRedo, canUndo, canRedo } = useHistory( + shapes, + onShapesChange + ) + + const commitShapes = useCallback((updated: ToolShape[]) => { + pushHistory(updated) + onShapesChange(updated) + }, [pushHistory, onShapesChange]) + + // refs to avoid stale closures during window-level drag handlers + const shapesRef = useRef(shapes) + const dragShapesRef = useRef(dragShapes) + const zoomRef = useRef(zoom) + const panRef = useRef(pan) + const gridRef = useRef(gridMm) + useEffect(() => { shapesRef.current = shapes }, [shapes]) + useEffect(() => { dragShapesRef.current = dragShapes }, [dragShapes]) + useEffect(() => { zoomRef.current = zoom }, [zoom]) + useEffect(() => { panRef.current = pan }, [pan]) + useEffect(() => { gridRef.current = gridMm }, [gridMm]) + + // viewBox from committed shapes + outline so the frame stays stable during drags + const bounds = (() => { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const s of shapes) { + const b = shapeBounds(s) + minX = Math.min(minX, b.minX); minY = Math.min(minY, b.minY) + maxX = Math.max(maxX, b.maxX); maxY = Math.max(maxY, b.maxY) + } + for (const p of outlinePoints) { + minX = Math.min(minX, p.x); minY = Math.min(minY, p.y) + maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y) + } + if (minX === Infinity) return { minX: -50, minY: -50, maxX: 50, maxY: 50 } + return { minX, minY, maxX, maxY } + })() + + const vbX = (bounds.minX - PADDING_MM) * DISPLAY_SCALE + const vbY = (bounds.minY - PADDING_MM) * DISPLAY_SCALE + const vbW = (bounds.maxX - bounds.minX + PADDING_MM * 2) * DISPLAY_SCALE + const vbH = (bounds.maxY - bounds.minY + PADDING_MM * 2) * DISPLAY_SCALE + const zvbW = vbW / zoom + const zvbH = vbH / zoom + const zvbX = vbX + (vbW - zvbW) / 2 + pan.x + const zvbY = vbY + (vbH - zvbH) / 2 + pan.y + + const gridStep = 10 + const gridMinX = Math.floor(zvbX / DISPLAY_SCALE / gridStep) * gridStep + const gridMaxX = Math.ceil((zvbX + zvbW) / DISPLAY_SCALE / gridStep) * gridStep + const gridMinY = Math.floor(zvbY / DISPLAY_SCALE / gridStep) * gridStep + const gridMaxY = Math.ceil((zvbY + zvbH) / DISPLAY_SCALE / gridStep) * gridStep + + const screenToMm = useCallback((clientX: number, clientY: number): Point => { + if (!svgRef.current) return { x: 0, y: 0 } + const rect = svgRef.current.getBoundingClientRect() + const scale = Math.max(zvbW / rect.width, zvbH / rect.height) + const offsetX = (rect.width * scale - zvbW) / 2 + const offsetY = (rect.height * scale - zvbH) / 2 + const svgX = (clientX - rect.left) * scale - offsetX + zvbX + const svgY = (clientY - rect.top) * scale - offsetY + zvbY + return { x: svgX / DISPLAY_SCALE, y: svgY / DISPLAY_SCALE } + }, [zvbW, zvbH, zvbX, zvbY]) + + const screenToMmRef = useRef(screenToMm) + useEffect(() => { screenToMmRef.current = screenToMm }, [screenToMm]) + + /** snap threshold in mm: ~8 screen px */ + const snapThresholdMm = useCallback((): number => { + if (!svgRef.current) return 1 + const rect = svgRef.current.getBoundingClientRect() + return (8 * Math.max(zvbW / rect.width, zvbH / rect.height)) / DISPLAY_SCALE + }, [zvbW, zvbH]) + const thresholdRef = useRef(snapThresholdMm) + useEffect(() => { thresholdRef.current = snapThresholdMm }, [snapThresholdMm]) + + // scroll-to-zoom toward the cursor + useEffect(() => { + const svg = svgRef.current + if (!svg) return + const handleWheel = (e: WheelEvent) => { + e.preventDefault() + const factor = e.deltaY < 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR + const oldZoom = zoomRef.current + const newZoom = Math.min(20, Math.max(0.5, oldZoom * factor)) + if (newZoom === oldZoom) return + + const rect = svg.getBoundingClientRect() + const curPan = panRef.current + const curW = vbW / oldZoom + const curH = vbH / oldZoom + const curX = vbX + (vbW - curW) / 2 + curPan.x + const curY = vbY + (vbH - curH) / 2 + curPan.y + const svgScale = Math.min(rect.width / curW, rect.height / curH) + const padLeft = (rect.width - curW * svgScale) / 2 + const padTop = (rect.height - curH * svgScale) / 2 + const cursorX = curX + (e.clientX - rect.left - padLeft) / svgScale + const cursorY = curY + (e.clientY - rect.top - padTop) / svgScale + const newW = vbW / newZoom + const newH = vbH / newZoom + const newX = vbX + (vbW - newW) / 2 + curPan.x + const newY = vbY + (vbH - newH) / 2 + curPan.y + const newSvgScale = Math.min(rect.width / newW, rect.height / newH) + const newPadLeft = (rect.width - newW * newSvgScale) / 2 + const newPadTop = (rect.height - newH * newSvgScale) / 2 + const newCursorX = newX + (e.clientX - rect.left - newPadLeft) / newSvgScale + const newCursorY = newY + (e.clientY - rect.top - newPadTop) / newSvgScale + setPan({ x: curPan.x + (cursorX - newCursorX), y: curPan.y + (cursorY - newCursorY) }) + setZoom(newZoom) + } + svg.addEventListener('wheel', handleWheel, { passive: false }) + return () => svg.removeEventListener('wheel', handleWheel) + }, [vbW, vbH, vbX, vbY]) + + // space for pan, delete for removing the selected shape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.code === 'Space' && !e.repeat) spaceHeld.current = true + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId) { + const tag = (document.activeElement?.tagName || '').toLowerCase() + if (tag === 'input' || tag === 'textarea') return + commitShapes(shapesRef.current.filter((s) => s.id !== selectedId)) + setSelectedId(null) + } + } + const handleKeyUp = (e: KeyboardEvent) => { + if (e.code === 'Space') spaceHeld.current = false + } + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } + }, [selectedId, commitShapes]) + + const handleShapeMouseDown = (id: string) => (e: React.MouseEvent) => { + e.stopPropagation() + if (spaceHeld.current) return + const shape = shapes.find((s) => s.id === id) + if (!shape) return + setSelectedId(id) + setDragging({ type: 'shape', id, startMm: screenToMm(e.clientX, e.clientY), orig: shape, alt: e.altKey }) + } + + const handleResizeMouseDown = (id: string) => (e: React.MouseEvent) => { + e.stopPropagation() + const shape = shapes.find((s) => s.id === id) + if (!shape) return + setDragging({ type: 'resize', id, orig: shape }) + } + + const handleRotateMouseDown = (id: string) => (e: React.MouseEvent) => { + e.stopPropagation() + const shape = shapes.find((s) => s.id === id) + if (!shape) return + const mm = screenToMm(e.clientX, e.clientY) + const startAngle = (Math.atan2(mm.y - shape.y, mm.x - shape.x) * 180) / Math.PI + setDragging({ type: 'rotate', id, orig: shape, startAngle, alt: e.altKey }) + } + + const handleSvgMouseDown = (e: React.MouseEvent) => { + if (e.button !== 0 && e.button !== 1) return + if (e.button === 1 || spaceHeld.current || e.target === svgRef.current || (e.target as Element).tagName === 'rect') { + // pan on background / middle button / space + if (!svgRef.current) return + const rect = svgRef.current.getBoundingClientRect() + const svgScale = Math.min(rect.width / zvbW, rect.height / zvbH) + didPanRef.current = false + setDragging({ type: 'pan', startClientX: e.clientX, startClientY: e.clientY, origPanX: pan.x, origPanY: pan.y, svgScale }) + } + } + + const handleBackgroundClick = () => { + if (!didPanRef.current) setSelectedId(null) + } + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!dragging) return + const grid = gridRef.current + + if (dragging.type === 'pan') { + const dx = (e.clientX - dragging.startClientX) / dragging.svgScale + const dy = (e.clientY - dragging.startClientY) / dragging.svgScale + if (Math.abs(dx) + Math.abs(dy) > 2) didPanRef.current = true + setPan({ x: dragging.origPanX - dx, y: dragging.origPanY - dy }) + return + } + + const mm = screenToMmRef.current(e.clientX, e.clientY) + const base = shapesRef.current + + if (dragging.type === 'shape') { + const candX = dragging.orig.x + (mm.x - dragging.startMm.x) + const candY = dragging.orig.y + (mm.y - dragging.startMm.y) + let next = { x: candX, y: candY } + let indicator: SnapIndicator | null = null + if (!e.altKey && !dragging.alt) { + const others = base.filter((s) => s.id !== dragging.id) + const snapped = snapShapePosition(dragging.orig, candX, candY, others, grid || null, thresholdRef.current()) + next = { x: snapped.x, y: snapped.y } + indicator = snapped.indicator + } + setSnapIndicator(indicator) + setDragShapes(base.map((s) => (s.id === dragging.id ? { ...s, x: next.x, y: next.y } : s))) + } else if (dragging.type === 'resize') { + const o = dragging.orig + const local = rotatePoint({ x: mm.x - o.x, y: mm.y - o.y }, -o.rotation) + const snapDim = (v: number) => { + const dim = Math.max(0.5, v) + return e.altKey || !grid ? dim : Math.max(0.5, Math.round(dim / grid) * grid) + } + let patch: Partial = {} + if (o.type === 'rectangle') { + patch = { width: snapDim(Math.abs(local.x) * 2), height: snapDim(Math.abs(local.y) * 2) } + } else if (o.type === 'ellipse') { + if (o.rx === o.ry) { + const r = snapDim(Math.hypot(local.x, local.y)) + patch = { rx: r, ry: r } + } else { + patch = { rx: snapDim(Math.abs(local.x)), ry: snapDim(Math.abs(local.y)) } + } + } + setDragShapes(base.map((s) => (s.id === dragging.id ? { ...s, ...patch } : s))) + } else if (dragging.type === 'rotate') { + const o = dragging.orig + const angle = (Math.atan2(mm.y - o.y, mm.x - o.x) * 180) / Math.PI + const next = snapRotation(o.rotation + angle - dragging.startAngle, !e.altKey && !dragging.alt) + setDragShapes(base.map((s) => (s.id === dragging.id ? { ...s, rotation: next } : s))) + } + }, [dragging]) + + const handleMouseUp = useCallback(() => { + if (dragging && dragging.type !== 'pan' && dragShapesRef.current) { + commitShapes(dragShapesRef.current) + } + setDragging(null) + setDragShapes(null) + setSnapIndicator(null) + }, [dragging, commitShapes]) + + useEffect(() => { + if (!dragging) return + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + }, [dragging, handleMouseMove, handleMouseUp]) + + return ( +
+ + + {/* floating toolbar: top centre */} +
+
+
+ +
+ {GRID_OPTIONS.map((opt) => ( + + ))} +
+ mm +
+ +
+ + + + + + Alt = no snap · Space/middle-drag = pan · Scroll = zoom + +
+
+ + {/* shape list panel: right edge */} +
+ +
+
+ ) +} diff --git a/frontend/src/components/ShapeDesignerCanvas.tsx b/frontend/src/components/ShapeDesignerCanvas.tsx new file mode 100644 index 0000000..a546cc6 --- /dev/null +++ b/frontend/src/components/ShapeDesignerCanvas.tsx @@ -0,0 +1,266 @@ +'use client' + +import { RefObject } from 'react' +import type { Point, ToolShape } from '@/types' +import { polygonPathData } from '@/lib/svg' +import { DISPLAY_SCALE } from '@/lib/constants' +import { shapeBounds } from '@/lib/shapes' +import type { SnapIndicator } from '@/lib/shapeSnap' + +const S = DISPLAY_SCALE + +interface Props { + svgRef: RefObject + zvbX: number + zvbY: number + zvbW: number + zvbH: number + zoom: number + gridMinX: number + gridMaxX: number + gridMinY: number + gridMaxY: number + gridStep: number + + shapes: ToolShape[] + selectedId: string | null + outlinePoints: Point[] + outlineRings: Point[][] + snapIndicator: SnapIndicator | null + + handleBackgroundClick: (e: React.MouseEvent) => void + handleSvgMouseDown: (e: React.MouseEvent) => void + handleShapeMouseDown: (id: string) => (e: React.MouseEvent) => void + handleResizeMouseDown: (id: string) => (e: React.MouseEvent) => void + handleRotateMouseDown: (id: string) => (e: React.MouseEvent) => void +} + +function ShapeElement({ shape, zoom, selected }: { shape: ToolShape; zoom: number; selected: boolean }) { + const sw = (selected ? 2.5 : 1.5) / zoom + const stroke = + shape.mode === 'guide' + ? 'rgb(96, 165, 250)' + : shape.mode === 'subtract' + ? 'rgb(248, 113, 113)' + : selected + ? 'rgb(90, 180, 222)' + : 'rgb(148, 163, 184)' + const dash = shape.mode === 'guide' ? `${8 / zoom},${5 / zoom}` : shape.mode === 'subtract' ? `${5 / zoom},${3 / zoom}` : undefined + const transform = `translate(${shape.x * S},${shape.y * S})${shape.rotation ? ` rotate(${shape.rotation})` : ''}` + + if (shape.type === 'rectangle') { + const w = (shape.width ?? 0) * S + const h = (shape.height ?? 0) * S + const r = (shape.corner_radius ?? 0) * S + return ( + + + + ) + } + if (shape.type === 'ellipse') { + return ( + + + + ) + } + // guide line + const hl = ((shape.width ?? 0) / 2) * S + return ( + + + + ) +} + +function MaskShape({ shape }: { shape: ToolShape }) { + const fill = shape.mode === 'add' ? 'white' : 'black' + const transform = `translate(${shape.x * S},${shape.y * S})${shape.rotation ? ` rotate(${shape.rotation})` : ''}` + if (shape.type === 'rectangle') { + const w = (shape.width ?? 0) * S + const h = (shape.height ?? 0) * S + const r = (shape.corner_radius ?? 0) * S + return + } + return +} + +export function ShapeDesignerCanvas({ + svgRef, zvbX, zvbY, zvbW, zvbH, zoom, + gridMinX, gridMaxX, gridMinY, gridMaxY, gridStep, + shapes, selectedId, outlinePoints, outlineRings, snapIndicator, + handleBackgroundClick, handleSvgMouseDown, + handleShapeMouseDown, handleResizeMouseDown, handleRotateMouseDown, +}: Props) { + const stopClick = (e: React.MouseEvent) => e.stopPropagation() + const s = zvbW / 800 + // solid shapes ordered so the mask builds add-then-subtract; guides never paint + const solidShapes = [...shapes.filter((sh) => sh.mode === 'add'), ...shapes.filter((sh) => sh.mode === 'subtract')] + const selected = shapes.find((sh) => sh.id === selectedId) + + return ( +
+ + + + {/* mm grid */} + {Array.from({ length: Math.ceil((gridMaxX - gridMinX) / gridStep) + 1 }).map((_, i) => { + const x = (gridMinX + i * gridStep) * S + const isOrigin = gridMinX + i * gridStep === 0 + return ( + + ) + })} + {Array.from({ length: Math.ceil((gridMaxY - gridMinY) / gridStep) + 1 }).map((_, i) => { + const y = (gridMinY + i * gridStep) * S + const isOrigin = gridMinY + i * gridStep === 0 + return ( + + ) + })} + + {/* live boolean preview: white = solid, black = hole */} + + + {solidShapes.map((sh) => ( + + ))} + + + + {/* authoritative outline from the last server materialization */} + {outlinePoints.length >= 3 && ( + + )} + + {/* per-shape strokes + hit areas */} + {shapes.map((sh) => ( + + + {/* hit target */} + {(() => { + const transform = `translate(${sh.x * S},${sh.y * S})${sh.rotation ? ` rotate(${sh.rotation})` : ''}` + const common = { + transform, + fill: sh.mode === 'guide' ? 'none' : 'transparent', + stroke: 'transparent', + strokeWidth: 14 / zoom, + className: 'cursor-move', + onMouseDown: handleShapeMouseDown(sh.id), + onClick: stopClick, + } + if (sh.type === 'rectangle') { + const w = (sh.width ?? 0) * S + const h = (sh.height ?? 0) * S + return + } + if (sh.type === 'ellipse') { + return + } + const hl = ((sh.width ?? 0) / 2) * S + return + })()} + + ))} + + {/* selection: bbox + resize + rotate handles */} + {selected && (() => { + const b = shapeBounds(selected) + const pad = 6 * s + const x1 = b.minX * S - pad + const y1 = b.minY * S - pad + const x2 = b.maxX * S + pad + const y2 = b.maxY * S + pad + const handleR = 9 * s + return ( + + + {selected.type !== 'line' && ( + + )} + + + + ) + })()} + + {/* snap indicators */} + {snapIndicator?.point && ( + + + + + + )} + {snapIndicator?.axisX !== undefined && ( + + )} + {snapIndicator?.axisY !== undefined && ( + + )} + +
+ ) +} diff --git a/frontend/src/components/ShapeListPanel.tsx b/frontend/src/components/ShapeListPanel.tsx new file mode 100644 index 0000000..93b0371 --- /dev/null +++ b/frontend/src/components/ShapeListPanel.tsx @@ -0,0 +1,230 @@ +'use client' + +import { Circle, Copy, Minus, Pencil, Plus, RectangleHorizontal, Trash2 } from 'lucide-react' +import type { ToolShape, ToolShapeMode } from '@/types' +import { NumberField } from '@/components/NumberField' +import { makeShape, duplicateShape, shapeDisplayName } from '@/lib/shapes' + +interface Props { + shapes: ToolShape[] + selectedId: string | null + onSelect: (id: string | null) => void + onShapesChange: (shapes: ToolShape[]) => void + clearanceOverride: number | null + onClearanceChange: (v: number | null) => void + materializeError: string | null + onConvertToPolygon: () => void +} + +const MODE_LABEL: Record = { + add: 'Solid', + subtract: 'Hole', + guide: 'Guide', +} + +const MODE_STYLE: Record = { + add: 'bg-accent-muted text-accent', + subtract: 'bg-red-900/40 text-red-400', + guide: 'bg-blue-900/40 text-blue-400', +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ) +} + +const FIELD_CLASS = + 'w-16 h-6 bg-elevated border border-border-subtle rounded text-right text-[11px] text-text-primary pr-1.5 focus:outline-none focus:border-accent' + +export function ShapeListPanel({ + shapes, + selectedId, + onSelect, + onShapesChange, + clearanceOverride, + onClearanceChange, + materializeError, + onConvertToPolygon, +}: Props) { + const update = (id: string, patch: Partial) => { + onShapesChange(shapes.map((s) => (s.id === id ? { ...s, ...patch } : s))) + } + + const add = (shape: ToolShape) => { + onShapesChange([...shapes, shape]) + onSelect(shape.id) + } + + const remove = (id: string) => { + onShapesChange(shapes.filter((s) => s.id !== id)) + if (selectedId === id) onSelect(null) + } + + const cycleMode = (s: ToolShape) => { + if (s.type === 'line') return // lines are always guides + const order: ToolShapeMode[] = ['add', 'subtract', 'guide'] + update(s.id, { mode: order[(order.indexOf(s.mode) + 1) % order.length] }) + } + + return ( +
+ {/* add buttons */} +
+ + + + Add shape +
+ + {/* shape rows */} + {shapes.map((s) => { + const selected = s.id === selectedId + const isCircle = s.type === 'ellipse' && s.rx === s.ry + return ( +
onSelect(s.id)} + className={`glass-toolbar px-2.5 py-2 cursor-pointer ${selected ? 'ring-1 ring-accent' : ''}`} + > +
+ {s.type === 'rectangle' ? ( + + ) : s.type === 'ellipse' ? ( + + ) : ( + + )} + {shapeDisplayName(s)} + + + +
+ + {selected && ( +
e.stopPropagation()}> + + update(s.id, { x: v })} className={FIELD_CLASS} /> + + + update(s.id, { y: v })} className={FIELD_CLASS} /> + + {s.type === 'rectangle' && ( + <> + + update(s.id, { width: v })} className={FIELD_CLASS} /> + + + update(s.id, { height: v })} className={FIELD_CLASS} /> + + + update(s.id, { corner_radius: v })} className={FIELD_CLASS} /> + + + )} + {s.type === 'ellipse' && isCircle && ( + + update(s.id, { rx: v / 2, ry: v / 2 })} className={FIELD_CLASS} /> + + )} + {s.type === 'ellipse' && ( + <> + + update(s.id, { rx: v / 2 })} className={FIELD_CLASS} /> + + + update(s.id, { ry: v / 2 })} className={FIELD_CLASS} /> + + + )} + {s.type === 'line' && ( + + update(s.id, { width: v })} className={FIELD_CLASS} /> + + )} + + update(s.id, { rotation: v })} className={FIELD_CLASS} /> + +
+ )} +
+ ) + })} + + {materializeError && ( +
+ {materializeError} — changes are not saved until the outline is valid again. +
+ )} + + {/* tool-level settings */} +
+ + onClearanceChange(v)} + onCommitNull={() => onClearanceChange(null)} + className={FIELD_CLASS} + /> + +

+ Cutouts grow by this much per side in bins. Leave blank to use each bin's clearance; set 0 for an exact fit. +

+

+ Holes are carved after all solids are merged. +

+ +
+
+ ) +} diff --git a/frontend/src/lib/shapeSnap.ts b/frontend/src/lib/shapeSnap.ts new file mode 100644 index 0000000..c068d8e --- /dev/null +++ b/frontend/src/lib/shapeSnap.ts @@ -0,0 +1,113 @@ +import type { Point, ToolShape } from '@/types' +import { salientPoints, projectOntoShape } from '@/lib/shapes' + +export interface SnapIndicator { + point?: Point // matched snap point (tool space mm) + axisX?: number // vertical alignment guide at this x + axisY?: number // horizontal alignment guide at this y +} + +export interface SnapResult { + x: number + y: number + indicator: SnapIndicator | null +} + +/** + * Snap a shape being dragged to (candidateX, candidateY). + * Precedence: salient point-to-point > guide edge projection > axis alignment > grid. + * Pass gridMm = null for grid off; bypass entirely (Alt) by not calling this. + */ +export function snapShapePosition( + dragged: ToolShape, + candidateX: number, + candidateY: number, + others: ToolShape[], + gridMm: number | null, + thresholdMm: number, +): SnapResult { + const at = { ...dragged, x: candidateX, y: candidateY } + const myPoints = salientPoints(at) + const targets: Point[] = [{ x: 0, y: 0 }] + for (const s of others) targets.push(...salientPoints(s)) + + // point-to-point + let best: { d: number; dx: number; dy: number; target: Point } | null = null + for (const mp of myPoints) { + for (const t of targets) { + const d = Math.hypot(t.x - mp.x, t.y - mp.y) + if (d < thresholdMm && (!best || d < best.d)) { + best = { d, dx: t.x - mp.x, dy: t.y - mp.y, target: t } + } + } + } + if (best) { + return { x: candidateX + best.dx, y: candidateY + best.dy, indicator: { point: best.target } } + } + + // projection onto guide edges (lines, circles, guide rects) + const guides = others.filter((s) => s.mode === 'guide') + let bestProj: { d: number; dx: number; dy: number; target: Point } | null = null + for (const g of guides) { + for (const mp of myPoints) { + const q = projectOntoShape(g, mp) + if (!q) continue + const d = Math.hypot(q.x - mp.x, q.y - mp.y) + if (d < thresholdMm && (!bestProj || d < bestProj.d)) { + bestProj = { d, dx: q.x - mp.x, dy: q.y - mp.y, target: q } + } + } + } + if (bestProj) { + return { + x: candidateX + bestProj.dx, + y: candidateY + bestProj.dy, + indicator: { point: bestProj.target }, + } + } + + // single-axis center alignment + let x = candidateX + let y = candidateY + const indicator: SnapIndicator = {} + let bestAx = thresholdMm + let bestAy = thresholdMm + for (const t of targets) { + const dx = Math.abs(t.x - candidateX) + if (dx < bestAx) { + bestAx = dx + x = t.x + indicator.axisX = t.x + } + const dy = Math.abs(t.y - candidateY) + if (dy < bestAy) { + bestAy = dy + y = t.y + indicator.axisY = t.y + } + } + + // grid on whichever axes didn't axis-snap + if (gridMm) { + if (indicator.axisX === undefined) x = Math.round(x / gridMm) * gridMm + if (indicator.axisY === undefined) y = Math.round(y / gridMm) * gridMm + } + + const hasIndicator = indicator.axisX !== undefined || indicator.axisY !== undefined + return { x, y, indicator: hasIndicator ? indicator : null } +} + +/** rotation snapping: 15-degree detents within a few degrees */ +export function snapRotation(deg: number, enabled: boolean): number { + if (!enabled) return normalizeDeg(deg) + const detent = Math.round(deg / 15) * 15 + if (Math.abs(deg - detent) < 4) return normalizeDeg(detent) + return normalizeDeg(deg) +} + +function normalizeDeg(deg: number): number { + let d = deg % 360 + if (d > 180) d -= 360 + if (d < -180) d += 360 + return Number(d.toFixed(1)) +} diff --git a/frontend/src/lib/shapes.ts b/frontend/src/lib/shapes.ts new file mode 100644 index 0000000..a3c50b1 --- /dev/null +++ b/frontend/src/lib/shapes.ts @@ -0,0 +1,136 @@ +import type { Point, ToolShape, ToolShapeMode, ToolShapeType } from '@/types' + +let shapeCounter = 0 + +export function makeShape(type: ToolShapeType, mode: ToolShapeMode = 'add'): ToolShape { + const id = `shape-${Date.now().toString(36)}-${shapeCounter++}` + switch (type) { + case 'rectangle': + return { id, type, mode, x: 0, y: 0, rotation: 0, width: 40, height: 40, corner_radius: 0 } + case 'ellipse': + return { id, type, mode, x: 0, y: 0, rotation: 0, rx: 10, ry: 10 } + case 'line': + return { id, type, mode: 'guide', x: 0, y: 0, rotation: 0, width: 50 } + } +} + +export function duplicateShape(shape: ToolShape): ToolShape { + return { ...shape, id: `shape-${Date.now().toString(36)}-${shapeCounter++}`, x: shape.x + 5, y: shape.y + 5 } +} + +export function rotatePoint(p: Point, deg: number): Point { + const rad = (deg * Math.PI) / 180 + const c = Math.cos(rad) + const s = Math.sin(rad) + return { x: p.x * c - p.y * s, y: p.x * s + p.y * c } +} + +function toWorld(shape: ToolShape, local: Point): Point { + const r = rotatePoint(local, shape.rotation) + return { x: shape.x + r.x, y: shape.y + r.y } +} + +/** points other shapes can snap to: center, corners, edge midpoints, quadrants, endpoints */ +export function salientPoints(shape: ToolShape): Point[] { + const pts: Point[] = [{ x: shape.x, y: shape.y }] + if (shape.type === 'rectangle') { + const hw = (shape.width ?? 0) / 2 + const hh = (shape.height ?? 0) / 2 + for (const [lx, ly] of [ + [-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh], // corners + [0, -hh], [hw, 0], [0, hh], [-hw, 0], // edge midpoints + ]) { + pts.push(toWorld(shape, { x: lx, y: ly })) + } + } else if (shape.type === 'ellipse') { + const rx = shape.rx ?? 0 + const ry = shape.ry ?? 0 + for (const [lx, ly] of [[rx, 0], [-rx, 0], [0, ry], [0, -ry]]) { + pts.push(toWorld(shape, { x: lx, y: ly })) + } + } else if (shape.type === 'line') { + const hl = (shape.width ?? 0) / 2 + pts.push(toWorld(shape, { x: -hl, y: 0 })) + pts.push(toWorld(shape, { x: hl, y: 0 })) + } + return pts +} + +/** axis-aligned bounds in tool space, accounting for rotation */ +export function shapeBounds(shape: ToolShape): { minX: number; minY: number; maxX: number; maxY: number } { + let corners: Point[] + if (shape.type === 'rectangle') { + const hw = (shape.width ?? 0) / 2 + const hh = (shape.height ?? 0) / 2 + corners = [ + toWorld(shape, { x: -hw, y: -hh }), + toWorld(shape, { x: hw, y: -hh }), + toWorld(shape, { x: hw, y: hh }), + toWorld(shape, { x: -hw, y: hh }), + ] + } else if (shape.type === 'ellipse') { + // bbox of a rotated ellipse + const rx = shape.rx ?? 0 + const ry = shape.ry ?? 0 + const rad = (shape.rotation * Math.PI) / 180 + const ex = Math.sqrt((rx * Math.cos(rad)) ** 2 + (ry * Math.sin(rad)) ** 2) + const ey = Math.sqrt((rx * Math.sin(rad)) ** 2 + (ry * Math.cos(rad)) ** 2) + return { minX: shape.x - ex, minY: shape.y - ey, maxX: shape.x + ex, maxY: shape.y + ey } + } else { + const hl = (shape.width ?? 0) / 2 + corners = [toWorld(shape, { x: -hl, y: 0 }), toWorld(shape, { x: hl, y: 0 })] + } + const xs = corners.map((p) => p.x) + const ys = corners.map((p) => p.y) + return { minX: Math.min(...xs), minY: Math.min(...ys), maxX: Math.max(...xs), maxY: Math.max(...ys) } +} + +/** nearest point on the shape's edge/curve to p (used for guide projection snapping) */ +export function projectOntoShape(shape: ToolShape, p: Point): Point | null { + // work in the shape's local frame + const rel = rotatePoint({ x: p.x - shape.x, y: p.y - shape.y }, -shape.rotation) + + let local: Point | null = null + if (shape.type === 'line') { + const hl = (shape.width ?? 0) / 2 + local = { x: Math.max(-hl, Math.min(hl, rel.x)), y: 0 } + } else if (shape.type === 'ellipse') { + const rx = shape.rx ?? 0 + const ry = shape.ry ?? 0 + if (rx <= 0 || ry <= 0) return null + // radial projection (exact for circles, good approximation for ellipses) + const a = Math.atan2(rel.y / ry, rel.x / rx) + local = { x: rx * Math.cos(a), y: ry * Math.sin(a) } + } else if (shape.type === 'rectangle') { + const hw = (shape.width ?? 0) / 2 + const hh = (shape.height ?? 0) / 2 + // nearest point on the rectangle's perimeter + const cx = Math.max(-hw, Math.min(hw, rel.x)) + const cy = Math.max(-hh, Math.min(hh, rel.y)) + if (Math.abs(cx) !== hw && Math.abs(cy) !== hh) { + // inside: push to the nearest edge + const dxEdge = hw - Math.abs(cx) + const dyEdge = hh - Math.abs(cy) + if (dxEdge < dyEdge) local = { x: Math.sign(cx || 1) * hw, y: cy } + else local = { x: cx, y: Math.sign(cy || 1) * hh } + } else { + local = { x: cx, y: cy } + } + } + if (!local) return null + const w = rotatePoint(local, shape.rotation) + return { x: shape.x + w.x, y: shape.y + w.y } +} + +export function shapeDisplayName(shape: ToolShape): string { + if (shape.type === 'rectangle') return `Rect ${fmt(shape.width)}×${fmt(shape.height)}` + if (shape.type === 'ellipse') { + if (shape.rx === shape.ry) return `Circle ⌀${fmt((shape.rx ?? 0) * 2)}` + return `Ellipse ${fmt((shape.rx ?? 0) * 2)}×${fmt((shape.ry ?? 0) * 2)}` + } + return `Guide line ${fmt(shape.width)}` +} + +function fmt(v: number | null | undefined): string { + return String(Number((v ?? 0).toFixed(2))) +} From 5b68b7a4d1f09a1d716d42f606d60a1a307adf4f Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Wed, 10 Jun 2026 14:17:13 -0400 Subject: [PATCH 05/11] docs: Document parametric shape tools and designer components Co-Authored-By: Claude Fable 5 --- docs/api.md | 13 +++++++++++-- docs/architecture.md | 10 +++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/api.md b/docs/api.md index 24d6b6d..732b784 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,11 +13,20 @@ - `DELETE /api/sessions/{id}` - delete session ## Tools (library) -- `GET /api/tools` - list tools +- `GET /api/tools` - list tools (`parametric` flag marks shape-designed tools) - `GET /api/tools/{id}` - get tool -- `PUT /api/tools/{id}` - update tool (name, points, finger_holes) +- `POST /api/tools` - create a parametric tool from shape primitives (defaults to a 40x40 rect) +- `PUT /api/tools/{id}` - update tool, returns the full Tool. For parametric tools, send `shapes` + (compiled server-side into points/interior_rings; 422 if the result isn't a single connected + outline) -- direct `points` edits are rejected until `shapes: null` detaches it to a plain + polygon. `clearance_override` (mm) beats the bin's `cutout_clearance` during generation. - `DELETE /api/tools/{id}` - delete tool +Shape primitives (`ToolShape`): `rectangle` (width/height/corner_radius), `ellipse` (rx/ry), +`line` (guide only); `mode` is `add` | `subtract` (island) | `guide` (construction, excluded +from the outline). All dimensions mm, positions in tool space, materialization recentres the +result on the bounding-box midpoint. See `backend/app/services/shape_compiler.py`. + ## Bins - `GET /api/bins` - list bins - `GET /api/bins/{id}` - get bin (syncs placed tools with library versions) diff --git a/docs/architecture.md b/docs/architecture.md index cc5e29f..f51583f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,6 +32,7 @@ tracefinity/ │ │ ├── ai_tracer.py # Gemini mask + contour tracing │ │ ├── image_processor.py # paper detection + perspective │ │ ├── polygon_scaler.py # px-to-mm, clearance, smoothing +│ │ ├── shape_compiler.py # parametric shapes -> outline (shapely booleans) │ │ ├── stl_generator_manifold.py # gridfinity STL + bin splitting │ │ ├── bin_service.py # placed-tool sync logic │ │ ├── image_service.py # tool thumbnail generation @@ -55,6 +56,11 @@ tracefinity/ │ │ │ ├── ToolEditor.tsx # tool editor orchestrator │ │ │ ├── ToolEditorToolbar.tsx # tool toolbar (mode, smooth, undo) │ │ │ ├── ToolEditorCanvas.tsx # tool SVG canvas +│ │ │ ├── ShapeDesigner.tsx # parametric designer (tools with `shapes`) +│ │ │ ├── ShapeDesignerCanvas.tsx # designer SVG canvas, mask boolean preview +│ │ │ ├── ShapeListPanel.tsx # shape rows with exact-mm inputs +│ │ │ ├── MeasurementOverlay.tsx # edge length / corner angle labels +│ │ │ ├── NumberField.tsx # commit-on-blur numeric input │ │ │ ├── ToolBrowser.tsx # sidebar tool picker for bins │ │ │ ├── PolygonEditor.tsx # trace-time polygon editor │ │ │ ├── CutoutOverlay.tsx # finger hole SVG rendering @@ -65,7 +71,9 @@ tracefinity/ │ │ └── lib/ │ │ ├── api.ts # API client │ │ ├── constants.ts # shared constants -│ │ └── svg.ts # polygon path, smoothing, snap +│ │ ├── shapes.ts # shape geometry (salient points, bounds, projection) +│ │ ├── shapeSnap.ts # designer snapping engine +│ │ └── svg.ts # polygon path, smoothing, snap, measurements │ └── package.json ├── .github/workflows/ │ ├── docker-dev.yml # build on push to main From e1df2789b6fbde6abd79b1059373c2d4d3b2f554 Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Wed, 10 Jun 2026 14:56:25 -0400 Subject: [PATCH 06/11] feat: Add auto-arrange packing for placed tools Palletize cutouts top-down: shelf packing (FFDH) of each tool's padded bounding box across candidate grids, picking the smallest grid footprint that fits everything (squarer on ties, capped at the 10x10 grid limit). Padding accounts for cutout clearance plus a printable web between pockets and the wall margin. - Auto-arrange toggle in the bin library strip re-packs on every add; Rotate toggle allows 90-degree orientation (both persisted in settings); Arrange button packs the current layout on demand - Rotation follows the editor/sync convention: points/holes/rings turn about the bbox centre and the delta accumulates on placed rotation - If not everything fits even at 10x10, the most items are placed, the rest keep their position, and a banner reports the overflow Co-Authored-By: Claude Fable 5 --- frontend/src/app/bins/[id]/page.tsx | 75 ++++++++- frontend/src/components/ToolBrowser.tsx | 5 +- frontend/src/lib/packing.ts | 202 ++++++++++++++++++++++++ frontend/src/lib/settings.ts | 9 +- 4 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/packing.ts diff --git a/frontend/src/app/bins/[id]/page.tsx b/frontend/src/app/bins/[id]/page.tsx index ce74f9c..e5dbd56 100644 --- a/frontend/src/app/bins/[id]/page.tsx +++ b/frontend/src/app/bins/[id]/page.tsx @@ -9,7 +9,8 @@ import { ToolBrowser } from '@/components/ToolBrowser' import { getBin, updateBin, generateBinStl, getBinStlUrl, getBinZipUrl, getBinThreemfUrl, getBinInsertUrl, getImageUrl, listTools, updateTool } from '@/lib/api' import { getSettings, saveSettings } from '@/lib/settings' import type { BinConfig, BinData, PlacedTool, TextLabel } from '@/types' -import { Download, Loader2, Package, ChevronDown, Check } from 'lucide-react' +import { Download, Loader2, Package, ChevronDown, Check, LayoutGrid, RotateCw, Sparkles } from 'lucide-react' +import { arrangeTools } from '@/lib/packing' import { Breadcrumb } from '@/components/Breadcrumb' import { Alert } from '@/components/Alert' import { useDebouncedSave } from '@/hooks/useDebouncedSave' @@ -90,10 +91,26 @@ export default function BinPage() { const [isDragging, setIsDragging] = useState(false) const [exportOpen, setExportOpen] = useState(false) const [snapMode, setSnapModeState] = useState('fixed-5') + const [autoArrange, setAutoArrangeState] = useState(false) + const [arrangeRotation, setArrangeRotationState] = useState(true) + const [layoutWarning, setLayoutWarning] = useState(null) const exportRef = useRef(null) useEffect(() => { - setSnapModeState(getSettings().snapMode) + const s = getSettings() + setSnapModeState(s.snapMode) + setAutoArrangeState(s.autoArrange) + setArrangeRotationState(s.arrangeRotation) + }, []) + + const handleAutoArrangeChange = useCallback((v: boolean) => { + setAutoArrangeState(v) + saveSettings({ autoArrange: v }) + }, []) + + const handleArrangeRotationChange = useCallback((v: boolean) => { + setArrangeRotationState(v) + saveSettings({ arrangeRotation: v }) }, []) const handleSnapModeChange = useCallback((m: SnapMode) => { @@ -300,7 +317,23 @@ export default function BinPage() { }, 300) }, []) + // pack all placed tools into the smallest grid footprint + const runArrange = useCallback((tools: PlacedTool[]) => { + const result = arrangeTools(tools, config, arrangeRotation) + if (!result) return false + setPlacedTools(result.tools) + setConfig(prev => (prev.grid_x === result.gridX && prev.grid_y === result.gridY + ? prev + : { ...prev, grid_x: result.gridX, grid_y: result.gridY })) + setLayoutWarning(result.unplacedIds.length > 0 + ? `${result.unplacedIds.length} tool${result.unplacedIds.length !== 1 ? 's' : ''} did not fit even at ${result.gridX}x${result.gridY} and kept ${result.unplacedIds.length !== 1 ? 'their' : 'its'} position` + : null) + return true + }, [config, arrangeRotation]) + const handleAddTool = useCallback((tool: PlacedTool) => { + if (autoArrange && runArrange([...placedTools, tool])) return + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity for (const p of tool.points) { minX = Math.min(minX, p.x) @@ -338,7 +371,7 @@ export default function BinPage() { } setPlacedTools(prev => [...prev, placed]) - }, [config.grid_x, config.grid_y, config.grid_unit_x_mm, config.grid_unit_y_mm, config.wall_thickness, config.cutout_clearance]) + }, [autoArrange, runArrange, placedTools, config.grid_x, config.grid_y, config.grid_unit_x_mm, config.grid_unit_y_mm, config.wall_thickness, config.cutout_clearance]) function handleDownload() { window.open(getBinStlUrl(binId), '_blank') @@ -420,6 +453,9 @@ export default function BinPage() { {warning && ( {warning} )} + {layoutWarning && ( + {layoutWarning} + )} {splitCount > 1 && ( Split into {splitCount} pieces )} @@ -487,6 +523,39 @@ export default function BinPage() { binWidthMm={binW} binHeightMm={binH} layout="horizontal" + headerExtra={ +
+ + + +
+ } />
diff --git a/frontend/src/components/ToolBrowser.tsx b/frontend/src/components/ToolBrowser.tsx index 1eea95c..de680ba 100644 --- a/frontend/src/components/ToolBrowser.tsx +++ b/frontend/src/components/ToolBrowser.tsx @@ -12,6 +12,8 @@ interface Props { binWidthMm: number binHeightMm: number layout?: 'grid' | 'horizontal' + /** extra controls rendered in the horizontal-layout header row */ + headerExtra?: React.ReactNode } function ToolThumbnail({ points, interiorRings }: { points: Point[]; interiorRings?: Point[][] }) { @@ -48,7 +50,7 @@ function ToolThumbnail({ points, interiorRings }: { points: Point[]; interiorRin ) } -export function ToolBrowser({ onAddTool, binWidthMm, binHeightMm, layout = 'grid' }: Props) { +export function ToolBrowser({ onAddTool, binWidthMm, binHeightMm, layout = 'grid', headerExtra }: Props) { const [tools, setTools] = useState([]) const [loading, setLoading] = useState(true) const [adding, setAdding] = useState(null) @@ -127,6 +129,7 @@ export function ToolBrowser({ onAddTool, binWidthMm, binHeightMm, layout = 'grid

Library

{tools.length} + {headerExtra} {tools.length > 4 && (
diff --git a/frontend/src/lib/packing.ts b/frontend/src/lib/packing.ts new file mode 100644 index 0000000..829d7dd --- /dev/null +++ b/frontend/src/lib/packing.ts @@ -0,0 +1,202 @@ +import type { PlacedTool, BinConfig } from '@/types' + +/** + * Top-down "palletizing" of placed tools: pack each tool's padded bounding + * box into the smallest grid footprint that holds them all, optionally + * allowing 90-degree rotation. Rectangle packing (shelf / first-fit + * decreasing height) is deliberate -- the pocket web spacing means irregular + * outlines rarely interlock, and bbox packing stays fast and predictable. + */ + +const MAX_GRID = 10 // BinParams caps grid_x/grid_y at 10 + +interface PackItem { + id: string + w: number // padded bbox, mm + h: number +} + +interface Placement { + x: number // padded bbox origin within the interior, mm + y: number + rotated: boolean +} + +interface Bounds { + minX: number + minY: number + maxX: number + maxY: number +} + +function toolBounds(pt: PlacedTool): Bounds { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const p of pt.points) { + minX = Math.min(minX, p.x); minY = Math.min(minY, p.y) + maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y) + } + for (const fh of pt.finger_holes) { + const r = fh.shape === 'rectangle' ? Math.max(fh.width || 0, fh.height || 0) / 2 : fh.radius + minX = Math.min(minX, fh.x - r); minY = Math.min(minY, fh.y - r) + maxX = Math.max(maxX, fh.x + r); maxY = Math.max(maxY, fh.y + r) + } + return { minX, minY, maxX, maxY } +} + +/** + * Shelf packing into a fixed W x H. Returns placements for the items that + * fit (greedy, biggest first); an item that doesn't fit is simply absent. + */ +function packShelves( + items: PackItem[], + binW: number, + binH: number, + allowRotation: boolean, +): Map { + // normalize to landscape when rotation is allowed; sort tallest first + const oriented = items.map((it) => { + const rotated = allowRotation && it.h > it.w + return { id: it.id, w: rotated ? it.h : it.w, h: rotated ? it.w : it.h, rotated } + }) + oriented.sort((a, b) => b.h - a.h || b.w - a.w) + + const placements = new Map() + const shelves: { y: number; height: number; xUsed: number }[] = [] + let nextY = 0 + + for (const it of oriented) { + let placed = false + for (const shelf of shelves) { + // try as-is, then the other orientation if rotation is allowed + if (it.w <= binW - shelf.xUsed && it.h <= shelf.height) { + placements.set(it.id, { x: shelf.xUsed, y: shelf.y, rotated: it.rotated }) + shelf.xUsed += it.w + placed = true + break + } + if (allowRotation && it.h <= binW - shelf.xUsed && it.w <= shelf.height) { + placements.set(it.id, { x: shelf.xUsed, y: shelf.y, rotated: !it.rotated }) + shelf.xUsed += it.h + placed = true + break + } + } + if (!placed) { + // open a new shelf; prefer the orientation with the lower shelf height + const fitsAsIs = it.h <= binH - nextY && it.w <= binW + const fitsRotated = allowRotation && it.w <= binH - nextY && it.h <= binW + if (fitsAsIs && (!fitsRotated || it.h <= it.w)) { + shelves.push({ y: nextY, height: it.h, xUsed: it.w }) + placements.set(it.id, { x: 0, y: nextY, rotated: it.rotated }) + nextY += it.h + } else if (fitsRotated) { + shelves.push({ y: nextY, height: it.w, xUsed: it.h }) + placements.set(it.id, { x: 0, y: nextY, rotated: !it.rotated }) + nextY += it.w + } + } + } + return placements +} + +export interface ArrangeResult { + tools: PlacedTool[] + gridX: number + gridY: number + unplacedIds: string[] +} + +export function arrangeTools( + placedTools: PlacedTool[], + config: BinConfig, + allowRotation: boolean, +): ArrangeResult | null { + if (placedTools.length === 0) return null + + // distance from bin edge to the bbox must cover wall + clearance (matches + // the auto-size margin); between two pockets we need both clearances plus + // a printable web, so each padded box carries clearance + web/2 per side + const web = Math.max(1.2, config.wall_thickness) + const pad = config.cutout_clearance + web / 2 + const edge = config.wall_thickness + 0.25 + + const boundsById = new Map(placedTools.map((pt) => [pt.id, toolBounds(pt)])) + const items: PackItem[] = placedTools.map((pt) => { + const b = boundsById.get(pt.id)! + return { id: pt.id, w: b.maxX - b.minX + 2 * pad, h: b.maxY - b.minY + 2 * pad } + }) + + const gux = config.grid_unit_x_mm + const guy = config.grid_unit_y_mm + + // candidate grids by footprint (cell count), squarer first on ties + const candidates: { gx: number; gy: number }[] = [] + for (let gx = 1; gx <= MAX_GRID; gx++) { + for (let gy = 1; gy <= MAX_GRID; gy++) candidates.push({ gx, gy }) + } + candidates.sort( + (a, b) => + a.gx * a.gy - b.gx * b.gy || + Math.abs(a.gx * gux - a.gy * guy) - Math.abs(b.gx * gux - b.gy * guy), + ) + + let best: { gx: number; gy: number; placements: Map } | null = null + for (const { gx, gy } of candidates) { + const interiorW = gx * gux - 2 * edge + const interiorH = gy * guy - 2 * edge + if (interiorW <= 0 || interiorH <= 0) continue + const placements = packShelves(items, interiorW, interiorH, allowRotation) + if (placements.size === placedTools.length) { + best = { gx, gy, placements } + break + } + // remember the fullest fallback (prefer more placed, then fewer cells -- + // candidates iterate smallest-first so first max wins) + if (!best || placements.size > best.placements.size) { + best = { gx, gy, placements } + } + } + if (!best) return null + + const unplacedIds: string[] = [] + const tools = placedTools.map((pt) => { + const placement = best!.placements.get(pt.id) + if (!placement) { + unplacedIds.push(pt.id) + return pt + } + const b = boundsById.get(pt.id)! + let points = pt.points + let fingerHoles = pt.finger_holes + let rings = pt.interior_rings ?? [] + let rotation = pt.rotation || 0 + let bb = b + + if (placement.rotated) { + // +90 degrees about the bbox centre; finger holes keep their own + // rotation value to match sync_placed_tools' convention + const cx = (b.minX + b.maxX) / 2 + const cy = (b.minY + b.maxY) / 2 + const rot = (x: number, y: number) => ({ x: cx - (y - cy), y: cy + (x - cx) }) + points = points.map((p) => rot(p.x, p.y)) + fingerHoles = fingerHoles.map((fh) => ({ ...fh, ...rot(fh.x, fh.y) })) + rings = rings.map((ring) => ring.map((p) => rot(p.x, p.y))) + rotation = (rotation + 90) % 360 + const halfW = (b.maxX - b.minX) / 2 + const halfH = (b.maxY - b.minY) / 2 + bb = { minX: cx - halfH, maxX: cx + halfH, minY: cy - halfW, maxY: cy + halfW } + } + + const dx = edge + placement.x + pad - bb.minX + const dy = edge + placement.y + pad - bb.minY + return { + ...pt, + points: points.map((p) => ({ x: p.x + dx, y: p.y + dy })), + finger_holes: fingerHoles.map((fh) => ({ ...fh, x: fh.x + dx, y: fh.y + dy })), + interior_rings: rings.map((ring) => ring.map((p) => ({ x: p.x + dx, y: p.y + dy }))), + rotation, + } + }) + + return { tools, gridX: best.gx, gridY: best.gy, unplacedIds } +} diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 8f7d308..e146580 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -3,9 +3,16 @@ import type { SnapMode } from './constants' export interface UserSettings { bedSize: number snapMode: SnapMode + autoArrange: boolean + arrangeRotation: boolean } -const DEFAULTS: UserSettings = { bedSize: 256, snapMode: 'fixed-5' } +const DEFAULTS: UserSettings = { + bedSize: 256, + snapMode: 'fixed-5', + autoArrange: false, + arrangeRotation: true, +} const KEY = 'tracefinity-settings' export function getSettings(): UserSettings { From 4fe0b2e880b459f67a7cc53160aef9059373c69c Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Wed, 10 Jun 2026 14:56:49 -0400 Subject: [PATCH 07/11] docs: Note packing.ts in architecture overview Co-Authored-By: Claude Fable 5 --- docs/architecture.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/architecture.md b/docs/architecture.md index f51583f..7c70439 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,6 +73,7 @@ tracefinity/ │ │ ├── constants.ts # shared constants │ │ ├── shapes.ts # shape geometry (salient points, bounds, projection) │ │ ├── shapeSnap.ts # designer snapping engine +│ │ ├── packing.ts # auto-arrange shelf packing for placed tools │ │ └── svg.ts # polygon path, smoothing, snap, measurements │ └── package.json ├── .github/workflows/ From 5c504034539ea6d3fb5eafc7792a3a1f1bdbbdce Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Wed, 10 Jun 2026 15:07:27 -0400 Subject: [PATCH 08/11] feat: Shift-drag locks movement to a cardinal axis in all editors Holding Shift while dragging constrains the move to whichever axis dominates the drag: trace-editor vertices, tool-editor vertices and cutout holes, placed tools in the bin editor, and shapes in the designer. The locked axis stays exactly where it started even when grid snap would otherwise pull it; the free axis still snaps. The designer shows an alignment guide along the locked axis. Co-Authored-By: Claude Fable 5 --- frontend/src/components/BinEditor.tsx | 13 ++++++---- frontend/src/components/PolygonEditor.tsx | 23 ++++++++++++----- frontend/src/components/ShapeDesigner.tsx | 23 ++++++++++++----- frontend/src/components/ToolEditor.tsx | 30 +++++++++++++++++------ frontend/src/lib/svg.ts | 5 ++++ 5 files changed, 70 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/BinEditor.tsx b/frontend/src/components/BinEditor.tsx index 0f07f81..633bfc9 100644 --- a/frontend/src/components/BinEditor.tsx +++ b/frontend/src/components/BinEditor.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react' import type { PlacedTool, TextLabel } from '@/types' -import { snapToGrid as snapToGridUtil } from '@/lib/svg' +import { snapToGrid as snapToGridUtil, axisLock } from '@/lib/svg' import { DEFAULT_GRID_UNIT, DISPLAY_SCALE, SNAP_GRID, resolveSnap, type SnapMode } from '@/lib/constants' import { BinEditorToolbar } from '@/components/BinEditorToolbar' import { BinEditorCanvas } from '@/components/BinEditorCanvas' @@ -281,6 +281,7 @@ export function BinEditor({ if (!dragging) return const clientX = e.clientX const clientY = e.clientY + const shiftKey = e.shiftKey if (rafRef.current) cancelAnimationFrame(rafRef.current) rafRef.current = requestAnimationFrame(() => { @@ -294,10 +295,12 @@ export function BinEditor({ if (dragging.type === 'tool') { const origCenterX = dragging.origPoints.reduce((sum, p) => sum + p.x, 0) / dragging.origPoints.length const origCenterY = dragging.origPoints.reduce((sum, p) => sum + p.y, 0) / dragging.origPoints.length - const rawDx = pos.x - dragging.startX - const rawDy = pos.y - dragging.startY - const newCenterX = snapToGrid(origCenterX + rawDx) - const newCenterY = snapToGrid(origCenterY + rawDy) + let rawDx = pos.x - dragging.startX + let rawDy = pos.y - dragging.startY + if (shiftKey) ({ dx: rawDx, dy: rawDy } = axisLock(rawDx, rawDy)) + // the shift-locked axis stays exactly put, even off-grid + const newCenterX = shiftKey && rawDx === 0 ? origCenterX : snapToGrid(origCenterX + rawDx) + const newCenterY = shiftKey && rawDy === 0 ? origCenterY : snapToGrid(origCenterY + rawDy) const dx = newCenterX - origCenterX const dy = newCenterY - origCenterY const updated = currentTools.map(tool => { diff --git a/frontend/src/components/PolygonEditor.tsx b/frontend/src/components/PolygonEditor.tsx index b89e3b6..9cc0f1d 100644 --- a/frontend/src/components/PolygonEditor.tsx +++ b/frontend/src/components/PolygonEditor.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect, useCallback } from 'react' import type { Point, Polygon } from '@/types' import { Undo2, Redo2, Trash2, Plus, Minus, Move, Ruler } from 'lucide-react' -import { polygonPathData } from '@/lib/svg' +import { polygonPathData, axisLock } from '@/lib/svg' import { useHistory } from '@/hooks/useHistory' import { MeasurementOverlay } from '@/components/MeasurementOverlay' @@ -24,7 +24,7 @@ const BASE_VIEW_WIDTH = 800 type EditMode = 'select' | 'vertex' | 'add-vertex' | 'delete-vertex' type DragState = - | { type: 'vertex'; polyId: string; pointIdx: number } + | { type: 'vertex'; polyId: string; pointIdx: number; startPoint: Point } | null export function PolygonEditor({ @@ -181,10 +181,16 @@ export function PolygonEditor({ } } + const startVertexDrag = (polyId: string, pointIdx: number) => { + const poly = polygons.find(p => p.id === polyId) + const startPoint = poly?.points[pointIdx] ?? { x: 0, y: 0 } + setDragging({ type: 'vertex', polyId, pointIdx, startPoint }) + } + const handleVertexMouseDown = (polyId: string, pointIdx: number) => (e: React.MouseEvent) => { e.stopPropagation() if (editable && (editMode === 'vertex' || editMode === 'select')) { - setDragging({ type: 'vertex', polyId, pointIdx }) + startVertexDrag(polyId, pointIdx) } } @@ -192,7 +198,7 @@ export function PolygonEditor({ e.stopPropagation() e.preventDefault() if (editable && (editMode === 'vertex' || editMode === 'select')) { - setDragging({ type: 'vertex', polyId, pointIdx }) + startVertexDrag(polyId, pointIdx) } } @@ -200,7 +206,12 @@ export function PolygonEditor({ (e: MouseEvent) => { if (!dragging) return - const point = getScaledPoint(e.clientX, e.clientY) + let point = getScaledPoint(e.clientX, e.clientY) + if (e.shiftKey && dragging.type === 'vertex') { + // shift constrains movement to the dominant cardinal axis + const d = axisLock(point.x - dragging.startPoint.x, point.y - dragging.startPoint.y) + point = { x: dragging.startPoint.x + d.dx, y: dragging.startPoint.y + d.dy } + } if (dragging.type === 'vertex') { const updated = polygonsRef.current.map((poly) => { @@ -377,7 +388,7 @@ export function PolygonEditor({ {(editMode === 'select' || editMode === 'vertex') && !activeId && 'Click outlines to select tools'} - {(editMode === 'select' || editMode === 'vertex') && activeId && 'Drag vertices to adjust the outline'} + {(editMode === 'select' || editMode === 'vertex') && activeId && 'Drag vertices to adjust the outline · Shift locks to an axis'} {editMode === 'add-vertex' && 'Click on an edge to add a vertex'} {editMode === 'delete-vertex' && 'Click a vertex to remove it'} diff --git a/frontend/src/components/ShapeDesigner.tsx b/frontend/src/components/ShapeDesigner.tsx index 1bb6397..4d912e9 100644 --- a/frontend/src/components/ShapeDesigner.tsx +++ b/frontend/src/components/ShapeDesigner.tsx @@ -4,6 +4,7 @@ import { useState, useRef, useCallback, useEffect } from 'react' import { Undo2, Redo2, Magnet } from 'lucide-react' import type { Point, ToolShape } from '@/types' import { DISPLAY_SCALE, ZOOM_FACTOR } from '@/lib/constants' +import { axisLock } from '@/lib/svg' import { rotatePoint, shapeBounds } from '@/lib/shapes' import { snapShapePosition, snapRotation, type SnapIndicator } from '@/lib/shapeSnap' import { useHistory } from '@/hooks/useHistory' @@ -253,13 +254,23 @@ export function ShapeDesigner({ const base = shapesRef.current if (dragging.type === 'shape') { - const candX = dragging.orig.x + (mm.x - dragging.startMm.x) - const candY = dragging.orig.y + (mm.y - dragging.startMm.y) - let next = { x: candX, y: candY } + let dx = mm.x - dragging.startMm.x + let dy = mm.y - dragging.startMm.y + let next = { x: dragging.orig.x + dx, y: dragging.orig.y + dy } let indicator: SnapIndicator | null = null - if (!e.altKey && !dragging.alt) { + if (e.shiftKey) { + // shift locks to the dominant cardinal axis; grid still snaps the + // free axis (unless Alt), the locked axis stays exactly put + ;({ dx, dy } = axisLock(dx, dy)) + const snapFree = (v: number) => (e.altKey || !grid ? v : Math.round(v / grid) * grid) + next = { + x: dx === 0 ? dragging.orig.x : snapFree(dragging.orig.x + dx), + y: dy === 0 ? dragging.orig.y : snapFree(dragging.orig.y + dy), + } + indicator = dx === 0 ? { axisX: dragging.orig.x } : { axisY: dragging.orig.y } + } else if (!e.altKey && !dragging.alt) { const others = base.filter((s) => s.id !== dragging.id) - const snapped = snapShapePosition(dragging.orig, candX, candY, others, grid || null, thresholdRef.current()) + const snapped = snapShapePosition(dragging.orig, next.x, next.y, others, grid || null, thresholdRef.current()) next = { x: snapped.x, y: snapped.y } indicator = snapped.indicator } @@ -370,7 +381,7 @@ export function ShapeDesigner({ - Alt = no snap · Space/middle-drag = pan · Scroll = zoom + Shift = axis lock · Alt = no snap · Space/middle-drag = pan · Scroll = zoom
diff --git a/frontend/src/components/ToolEditor.tsx b/frontend/src/components/ToolEditor.tsx index 89d5347..c95275a 100644 --- a/frontend/src/components/ToolEditor.tsx +++ b/frontend/src/components/ToolEditor.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useCallback, useEffect, useMemo } from 'react' import { Plus, Circle, Disc, Square, RectangleHorizontal, Fingerprint } from 'lucide-react' import type { Point, FingerHole } from '@/types' -import { simplifyPolygon, smoothEpsilon, snapToGrid as snapToGridUtil } from '@/lib/svg' +import { simplifyPolygon, smoothEpsilon, snapToGrid as snapToGridUtil, axisLock } from '@/lib/svg' import { DISPLAY_SCALE, SNAP_GRID, ZOOM_FACTOR } from '@/lib/constants' import { useHistory } from '@/hooks/useHistory' import { ToolEditorToolbar } from '@/components/ToolEditorToolbar' @@ -26,7 +26,7 @@ interface Props { const PADDING_MM = 20 type DragState = - | { type: 'vertex'; pointIdx: number } + | { type: 'vertex'; pointIdx: number; startPoint: Point } | { type: 'hole'; holeId: string; startX: number; startY: number; origX: number; origY: number } | { type: 'resize'; holeId: string; startX: number; startY: number; origRadius: number; origWidth?: number; origHeight?: number; centerX: number; centerY: number; anchorX?: number; anchorY?: number; rotation?: number } | { type: 'rotate-hole'; holeId: string; centerX: number; centerY: number; startAngle: number; origRotation: number } @@ -322,7 +322,7 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot return } setSelection({ type: 'vertex', pointIdx }) - setDragging({ type: 'vertex', pointIdx }) + setDragging({ type: 'vertex', pointIdx, startPoint: points[pointIdx] }) } const handleEdgeClick = (edgeIdx: number) => (e: React.MouseEvent) => { @@ -439,14 +439,30 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot if (dragging.type === 'vertex') { const updated = [...currentPoints] - updated[dragging.pointIdx] = { x: snap(pos.x), y: snap(pos.y) } + if (e.shiftKey) { + // shift constrains movement to the dominant cardinal axis; the + // locked axis stays exactly put, even off-grid + const d = axisLock(pos.x - dragging.startPoint.x, pos.y - dragging.startPoint.y) + updated[dragging.pointIdx] = { + x: d.dx === 0 ? dragging.startPoint.x : snap(dragging.startPoint.x + d.dx), + y: d.dy === 0 ? dragging.startPoint.y : snap(dragging.startPoint.y + d.dy), + } + } else { + updated[dragging.pointIdx] = { x: snap(pos.x), y: snap(pos.y) } + } setDragPoints(updated) } else if (dragging.type === 'hole') { - const dx = pos.x - dragging.startX - const dy = pos.y - dragging.startY + let dx = pos.x - dragging.startX + let dy = pos.y - dragging.startY + const locked = e.shiftKey + if (locked) ({ dx, dy } = axisLock(dx, dy)) const updated = currentHoles.map(fh => { if (fh.id !== dragging.holeId) return fh - return { ...fh, x: snap(dragging.origX + dx), y: snap(dragging.origY + dy) } + return { + ...fh, + x: locked && dx === 0 ? dragging.origX : snap(dragging.origX + dx), + y: locked && dy === 0 ? dragging.origY : snap(dragging.origY + dy), + } }) setDragHoles(updated) } else if (dragging.type === 'resize') { diff --git a/frontend/src/lib/svg.ts b/frontend/src/lib/svg.ts index 655c892..bafa59d 100644 --- a/frontend/src/lib/svg.ts +++ b/frontend/src/lib/svg.ts @@ -117,6 +117,11 @@ export function snapToGrid(v: number, grid: number): number { return Math.round(v / grid) * grid } +/** constrain a drag delta to its dominant cardinal axis (Shift-drag) */ +export function axisLock(dx: number, dy: number): { dx: number; dy: number } { + return Math.abs(dx) >= Math.abs(dy) ? { dx, dy: 0 } : { dx: 0, dy } +} + // --- measurement geometry --- export function signedArea(pts: Point[]): number { From e4e45b44ecbedc4431030fb7b78cc7ad922a1bca Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Wed, 10 Jun 2026 15:31:27 -0400 Subject: [PATCH 09/11] docs: Updated readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 6e301cc..dcc61bb 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,10 @@ No API key and prefer not to use the local model? Upload a mask manually: [Gridfinity](https://gridfinity.xyz/) is a modular storage system designed by [Zack Freedman](https://www.youtube.com/watch?v=ra_9zU-mnl8). Bins snap into baseplates on a 42mm grid, making it easy to organise tools, components, and supplies. The system is open source and hugely popular in the 3D printing community. +## AI Disclosure + +Parts of this project were developed with the assistance of AI coding tools (Claude Code). AI was used to help design, implement, and document features such as the parametric shape tools, CAD-style shape designer, and auto-arrange packing. All AI-assisted contributions were reviewed by a human before being merged. + ## Licence MIT From a97abff05d4618261d49c8356af5bc608c76eb32 Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Wed, 10 Jun 2026 15:57:08 -0400 Subject: [PATCH 10/11] ci: Updated pipelines --- .claude/settings.local.json | 12 +++- .github/workflows/ci.yml | 59 +++++++++++++++++++ frontend/eslint.config.mjs | 16 +++++ frontend/next-env.d.ts | 2 +- frontend/package.json | 3 +- frontend/src/app/page.tsx | 15 +++-- frontend/src/components/BinEditorToolbar.tsx | 11 ++-- frontend/src/components/NumberField.tsx | 14 +++-- frontend/src/components/PaperCornerEditor.tsx | 3 + frontend/src/components/SettingsPopover.tsx | 6 +- frontend/src/components/ThemeToggle.tsx | 56 +++++++++++++----- frontend/src/components/ToolEditor.tsx | 2 +- frontend/src/hooks/useHistory.ts | 4 +- frontend/vitest.config.ts | 14 +++++ 14 files changed, 178 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/vitest.config.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 27edd15..093d660 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,6 +1,14 @@ { + "permissions": { + "allow": [ + "Bash(npx tsc *)", + "Bash(npx vitest *)", + "Bash(sed -n '1,8p' src/components/BinConfigurator.test.ts)", + "Bash(npm run *)" + ] + }, + "enableAllProjectMcpServers": true, "enabledMcpjsonServers": [ "ssh" - ], - "enableAllProjectMcpServers": true + ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bca7a63 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: | + cd backend + pip install -r requirements.txt + pip install pytest + + - name: Run pytest + run: cd backend && pytest + + frontend: + name: Frontend lint, typecheck & unit tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: cd frontend && npm ci + + - name: Typecheck + run: cd frontend && npx tsc --noEmit + + - name: Unit tests + run: cd frontend && npm test + + # Non-blocking: surfaces existing lint debt without failing the build. + # Flip to a gate once the pre-existing errors are cleared. + - name: Lint + run: cd frontend && npm run lint + continue-on-error: true diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..809ee63 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,16 @@ +import next from 'eslint-config-next' + +const config = [ + { ignores: ['.next/**', 'node_modules/**', 'next-env.d.ts'] }, + ...next, + { + rules: { + // Tracefinity renders dynamic, cache-busted, and canvas-derived image URLs + // (photos, masks, blob URLs) where next/image's optimizer is not applicable + // and would break cache-busting / forced remounts. is intentional here. + '@next/next/no-img-element': 'off', + }, + }, +] + +export default config diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index c4b7818..9edff1c 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/package.json b/frontend/package.json index c4690e1..d834441 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev -p 4001", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "eslint .", + "test": "vitest run" }, "dependencies": { "@react-three/drei": "^10.7.7", diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index f50379a..29dfd4c 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -90,11 +90,18 @@ function NameModal({ open, onConfirm, onCancel }: { const [value, setValue] = useState('') const inputRef = useRef(null) + // reset the field each time the modal opens -- adjust during render rather + // than in an effect to avoid a cascading re-render + const [wasOpen, setWasOpen] = useState(open) + if (open !== wasOpen) { + setWasOpen(open) + if (open) setValue('') + } + useEffect(() => { - if (open) { - setValue('') - setTimeout(() => inputRef.current?.focus(), 50) - } + if (!open) return + const t = setTimeout(() => inputRef.current?.focus(), 50) + return () => clearTimeout(t) }, [open]) useEffect(() => { diff --git a/frontend/src/components/BinEditorToolbar.tsx b/frontend/src/components/BinEditorToolbar.tsx index fb8f902..5892a92 100644 --- a/frontend/src/components/BinEditorToolbar.tsx +++ b/frontend/src/components/BinEditorToolbar.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { MousePointer2, Trash2, Magnet, Type, Pencil, Maximize2 } from 'lucide-react' import type { FingerHole, PlacedTool, TextLabel } from '@/types' import { SNAP_GRID } from '@/lib/constants' @@ -16,10 +16,13 @@ interface DepthInputProps { function DepthInput({ value, defaultDepth, maxDepth, onCommit, resetKey }: DepthInputProps) { const [text, setText] = useState(value == null ? '' : String(value)) - // sync local text when the selected item changes (resetKey switches) - useEffect(() => { + // sync local text when the selected item changes (resetKey switches) or the + // committed value updates -- adjusting during render avoids a cascading effect + const [synced, setSynced] = useState({ resetKey, value }) + if (synced.resetKey !== resetKey || synced.value !== value) { + setSynced({ resetKey, value }) setText(value == null ? '' : String(value)) - }, [resetKey, value]) + } const commit = (raw: string) => { const trimmed = raw.trim() diff --git a/frontend/src/components/NumberField.tsx b/frontend/src/components/NumberField.tsx index 7fe6b1a..a31f7eb 100644 --- a/frontend/src/components/NumberField.tsx +++ b/frontend/src/components/NumberField.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' interface NumberFieldProps { value: number | null @@ -45,10 +45,14 @@ export function NumberField({ const [text, setText] = useState(() => format(value, step)) const [focused, setFocused] = useState(false) - // follow external changes (e.g. paired slider drags) while not being typed in - useEffect(() => { - if (!focused) setText(format(value, step)) - }, [value, step, focused]) + // follow external changes (e.g. paired slider drags) while not being typed in. + // adjusting during render (vs an effect) is the recommended pattern for + // state derived from props -- avoids an extra render and a cascading update. + const [synced, setSynced] = useState({ value, step }) + if (!focused && (synced.value !== value || synced.step !== step)) { + setSynced({ value, step }) + setText(format(value, step)) + } const revert = () => setText(format(value, step)) diff --git a/frontend/src/components/PaperCornerEditor.tsx b/frontend/src/components/PaperCornerEditor.tsx index 8f39d97..8738860 100644 --- a/frontend/src/components/PaperCornerEditor.tsx +++ b/frontend/src/components/PaperCornerEditor.tsx @@ -38,6 +38,9 @@ export function PaperCornerEditor({ imageUrl, corners, onCornersChange }: Props) } img.src = imageUrl return () => { cancelled = true } + // intentionally keyed off imageUrl only: corners/onCornersChange are read + // once at load to seed defaults; including them would reload on every drag + // eslint-disable-next-line react-hooks/exhaustive-deps }, [imageUrl]) // fit container to available space while preserving aspect ratio diff --git a/frontend/src/components/SettingsPopover.tsx b/frontend/src/components/SettingsPopover.tsx index e852667..b02511e 100644 --- a/frontend/src/components/SettingsPopover.tsx +++ b/frontend/src/components/SettingsPopover.tsx @@ -8,13 +8,9 @@ import { NumberField } from '@/components/NumberField' export function SettingsPopover() { const [open, setOpen] = useState(false) - const [bedSize, setBedSize] = useState(256) + const [bedSize, setBedSize] = useState(() => getSettings().bedSize) const ref = useRef(null) - useEffect(() => { - setBedSize(getSettings().bedSize) - }, []) - useEffect(() => { if (!open) return function handleClick(e: MouseEvent) { diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index df5b563..4c170f4 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -1,28 +1,54 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useSyncExternalStore } from 'react' import { Sun, Moon } from 'lucide-react' import { IconButton } from '@/components/IconButton' +type Theme = 'dark' | 'light' + +const listeners = new Set<() => void>() + +function resolveTheme(): Theme { + const stored = localStorage.getItem('theme') + if (stored === 'light' || stored === 'dark') return stored + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark' +} + +function applyTheme(theme: Theme) { + document.documentElement.setAttribute('data-theme', theme) +} + +function subscribe(callback: () => void): () => void { + listeners.add(callback) + const mq = window.matchMedia('(prefers-color-scheme: light)') + mq.addEventListener('change', callback) + return () => { + listeners.delete(callback) + mq.removeEventListener('change', callback) + } +} + +// Server has no DOM/storage; render the default theme to match initial markup. +function getServerSnapshot(): Theme { + return 'dark' +} + +function setTheme(theme: Theme) { + localStorage.setItem('theme', theme) + applyTheme(theme) + listeners.forEach((l) => l()) +} + export function ThemeToggle() { - const [theme, setTheme] = useState<'dark' | 'light'>('dark') + const theme = useSyncExternalStore(subscribe, resolveTheme, getServerSnapshot) + // Reflect the resolved theme onto the DOM: initial load and OS-preference changes. useEffect(() => { - const stored = localStorage.getItem('theme') - if (stored === 'light' || stored === 'dark') { - setTheme(stored) - document.documentElement.setAttribute('data-theme', stored) - } else if (window.matchMedia('(prefers-color-scheme: light)').matches) { - setTheme('light') - document.documentElement.setAttribute('data-theme', 'light') - } - }, []) + applyTheme(theme) + }, [theme]) function toggle() { - const next = theme === 'dark' ? 'light' : 'dark' - setTheme(next) - document.documentElement.setAttribute('data-theme', next) - localStorage.setItem('theme', next) + setTheme(theme === 'dark' ? 'light' : 'dark') } return ( diff --git a/frontend/src/components/ToolEditor.tsx b/frontend/src/components/ToolEditor.tsx index c95275a..f7ed434 100644 --- a/frontend/src/components/ToolEditor.tsx +++ b/frontend/src/components/ToolEditor.tsx @@ -60,7 +60,7 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot onInteriorRingsChange?.(entry.interiorRings) }, [onPointsChange, onFingerHolesChange, onInteriorRingsChange]) - const currentRings = interiorRings ?? [] + const currentRings = useMemo(() => interiorRings ?? [], [interiorRings]) const { set: pushHistory, undo: handleUndo, redo: handleRedo, canUndo, canRedo } = useHistory( { points, fingerHoles, interiorRings: currentRings }, diff --git a/frontend/src/hooks/useHistory.ts b/frontend/src/hooks/useHistory.ts index 0d82571..3ef5bd2 100644 --- a/frontend/src/hooks/useHistory.ts +++ b/frontend/src/hooks/useHistory.ts @@ -16,7 +16,9 @@ export function useHistory( const [index, setIndex] = useState(0) const isUndoRedoRef = useRef(false) const onChangeRef = useRef(onChange) - onChangeRef.current = onChange + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) const canUndo = index > 0 const canRedo = index < entries.length - 1 diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..340f647 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + environment: 'node', + include: ['src/**/*.{test,spec}.{ts,tsx}'], + }, +}) From 2e9749f5ae298874966f0da7ae6746fec5a5d794 Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Wed, 10 Jun 2026 16:10:56 -0400 Subject: [PATCH 11/11] fix: Gate readiness on /api/ready, not nginx-only boot.json The ReadinessGate only dropped its full-screen overlay once /boot.json reported ready, but boot.json is written by the container's nginx and does not exist under `npm run dev` / from-source. The gate therefore never lifted outside Docker, blocking all clicks behind the splash -- which broke the e2e suite (Continue button intercepted by the overlay) and local from-source dev. Use /api/ready as the authoritative signal (a 200 means uvicorn is bound and tracers are preloaded, per the backend) and treat boot.json as best-effort progress display only. Works with or without nginx. Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/ReadinessGate.tsx | 39 +++++++++++------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/ReadinessGate.tsx b/frontend/src/components/ReadinessGate.tsx index a393ada..27f1c01 100644 --- a/frontend/src/components/ReadinessGate.tsx +++ b/frontend/src/components/ReadinessGate.tsx @@ -61,30 +61,29 @@ export function ReadinessGate({ children }: { children: React.ReactNode }) { let nextDelay = POLL_INTERVAL_MS let success = false - // /boot.json is served by nginx and is always reachable once the - // container is up. /api/ready confirms uvicorn has bound the socket. + // /boot.json is served by nginx in the container to surface startup + // progress. It does not exist under `npm run dev` / from-source, so it's + // best-effort: we use it only to populate the progress display. try { const res = await fetchWithTimeout('/boot.json', FETCH_TIMEOUT_MS) - if (res.ok) { - const data = await res.json() as BootInfo - setBoot(data) - if (data.ready) { - // Confirm uvicorn is actually answering before we drop the splash. - try { - const ready = await fetchWithTimeout('/api/ready', FETCH_TIMEOUT_MS) - if (ready.ok) { - failsRef.current = 0 - setMode('ready') - nextDelay = HEARTBEAT_INTERVAL_MS - success = true - } - } catch { /* fall through to retry */ } - } - } else if (res.status === 503) { - // boot.json missing — backend hasn't written it yet, just retry. + if (res.ok) setBoot(await res.json() as BootInfo) + } catch { + // boot.json unreachable — fall back to /api/ready below. + } + + // /api/ready is the authoritative signal: it only answers 200 once + // uvicorn has bound, which (per the backend) means the tracer pre-load + // is complete. This works with or without nginx in front. + try { + const ready = await fetchWithTimeout('/api/ready', FETCH_TIMEOUT_MS) + if (ready.ok) { + failsRef.current = 0 + setMode('ready') + nextDelay = HEARTBEAT_INTERVAL_MS + success = true } } catch { - // network error reaching nginx — container is probably down. + // backend not answering yet — retry. } if (!success) {