From 93a25c1e7b68d9cf24fb2afad96e9fef413a92d7 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 12 Jan 2026 11:16:06 -0600 Subject: [PATCH] feat(vision): enhance quad detection with Hough lines and multi-strategy preprocessing - Add Hough line detection for improved edge finding with finger occlusion - Implement multi-strategy preprocessing (standard, enhanced, adaptive, multi) - Add configurable parameters for Canny thresholds, adaptive threshold, morph gradient - Refactor useDocumentDetection hook with cleaner API - Add OpenCV type definitions and async loading improvements - Add loader test pages for debugging OpenCV initialization - Add quad-test page for interactive detection testing - Add document detection research notes Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 11 +- .../components/NavSyncIndicator.tsx | 4 +- .../vision-training/hooks/useSyncStatus.ts | 4 +- .../loader-test-async/page.tsx | 3 +- .../vision-training/loader-test-bare/page.tsx | 3 +- .../loader-test-check/page.tsx | 4 +- .../loader-test-direct/page.tsx | 7 +- .../loader-test-hook-custom/page.tsx | 7 +- .../vision-training/loader-test-hook/page.tsx | 3 +- .../loader-test-inline/page.tsx | 7 +- .../loader-test-script/page.tsx | 4 +- .../loader-test-simple/page.tsx | 3 +- .../vision-training/loader-test-v2/page.tsx | 7 +- .../vision-training/loader-test-v3/page.tsx | 3 +- .../vision-training/loader-test-v4/page.tsx | 3 +- .../vision-training/loader-test-v5/page.tsx | 7 +- .../vision-training/loader-test-wait/page.tsx | 7 +- .../loader-test-wrapped/page.tsx | 7 +- .../app/vision-training/loader-test/page.tsx | 7 +- .../app/vision-training/quad-test/page.tsx | 1342 ++++++++++++++++- .../data-panel/UnifiedDataPanel.tsx | 4 +- .../practice/useDocumentDetection.ts | 637 ++------ apps/web/src/hooks/useOpenCV.ts | 6 +- apps/web/src/hooks/useQuadDetection.ts | 29 +- .../lib/vision/DOCUMENT_DETECTION_RESEARCH.md | 241 +++ apps/web/src/lib/vision/opencv/simpleAsync.ts | 2 +- apps/web/src/lib/vision/opencv/types.ts | 75 + apps/web/src/lib/vision/quadDetector.ts | 1025 ++++++++++++- apps/web/src/lib/vision/useQuadDetection.ts | 266 ++++ 29 files changed, 3091 insertions(+), 637 deletions(-) create mode 100644 apps/web/src/lib/vision/DOCUMENT_DETECTION_RESEARCH.md create mode 100644 apps/web/src/lib/vision/useQuadDetection.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 55b1f2e9..84b253ee 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -641,7 +641,16 @@ "Bash(if [ -f /Users/antialias/projects/soroban-abacus-flashcards/apps/web/data/vision-training/collected/.deleted ])", "Bash(then wc -l /Users/antialias/projects/soroban-abacus-flashcards/apps/web/data/vision-training/collected/.deleted)", "Bash(else echo \"File does not exist\")", - "Bash(fi)" + "Bash(fi)", + "WebFetch(domain:docs.opencv.org)", + "mcp__chrome-devtools__new_page", + "mcp__chrome-devtools__close_page", + "WebFetch(domain:www.npmjs.com)", + "Bash(git branch:*)", + "WebFetch(domain:scanbot.io)", + "WebFetch(domain:learnopencv.com)", + "WebFetch(domain:news.ycombinator.com)", + "Bash(npm run typecheck:*)" ], "deny": [], "ask": [] diff --git a/apps/web/src/app/vision-training/components/NavSyncIndicator.tsx b/apps/web/src/app/vision-training/components/NavSyncIndicator.tsx index 34ccdc79..949e51ca 100644 --- a/apps/web/src/app/vision-training/components/NavSyncIndicator.tsx +++ b/apps/web/src/app/vision-training/components/NavSyncIndicator.tsx @@ -339,8 +339,8 @@ export function NavSyncIndicator({ sync }: NavSyncIndicatorProps) { {sync.status?.local && sync.status?.remote && (
- Local: {sync.status.local.totalImages?.toLocaleString() || 0} •{' '} - Remote: {sync.status.remote.totalImages?.toLocaleString() || 0} + Local: {sync.status.local.totalImages?.toLocaleString() || 0} • Remote:{' '} + {sync.status.remote.totalImages?.toLocaleString() || 0}
)} diff --git a/apps/web/src/app/vision-training/hooks/useSyncStatus.ts b/apps/web/src/app/vision-training/hooks/useSyncStatus.ts index b0497613..be50c55e 100644 --- a/apps/web/src/app/vision-training/hooks/useSyncStatus.ts +++ b/apps/web/src/app/vision-training/hooks/useSyncStatus.ts @@ -118,7 +118,9 @@ export function useSyncStatus(modelType: ModelType): UseSyncStatusResult { const refreshHistory = useCallback(async () => { setHistoryLoading(true) try { - const response = await fetch(`/api/vision-training/sync/history?modelType=${modelType}&limit=5`) + const response = await fetch( + `/api/vision-training/sync/history?modelType=${modelType}&limit=5` + ) if (response.ok) { const data = await response.json() setHistory(data.history || []) diff --git a/apps/web/src/app/vision-training/loader-test-async/page.tsx b/apps/web/src/app/vision-training/loader-test-async/page.tsx index 29978dda..0b514949 100644 --- a/apps/web/src/app/vision-training/loader-test-async/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-async/page.tsx @@ -77,7 +77,8 @@ export default function LoaderTestAsyncPage() { Status:{' '} {status} diff --git a/apps/web/src/app/vision-training/loader-test-bare/page.tsx b/apps/web/src/app/vision-training/loader-test-bare/page.tsx index a5289e44..8439b3ac 100644 --- a/apps/web/src/app/vision-training/loader-test-bare/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-bare/page.tsx @@ -80,7 +80,8 @@ export default function LoaderTestBarePage() { Status:{' '} {status} diff --git a/apps/web/src/app/vision-training/loader-test-check/page.tsx b/apps/web/src/app/vision-training/loader-test-check/page.tsx index 75ae1e25..d5b6d811 100644 --- a/apps/web/src/app/vision-training/loader-test-check/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-check/page.tsx @@ -32,9 +32,7 @@ export default function LoaderTestCheckPage() { gap: 4, })} > -

- Check window.cv (No Loading) -

+

Check window.cv (No Loading)

Just checks if window.cv exists - no loading.

diff --git a/apps/web/src/app/vision-training/loader-test-direct/page.tsx b/apps/web/src/app/vision-training/loader-test-direct/page.tsx index 3c3ce7b9..6ed3c933 100644 --- a/apps/web/src/app/vision-training/loader-test-direct/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-direct/page.tsx @@ -47,9 +47,7 @@ export default function LoaderTestDirectPage() { gap: 4, })} > -

- Direct Import Test -

+

Direct Import Test

Imports directly from loader.ts (not barrel index.ts).

@@ -80,7 +78,8 @@ export default function LoaderTestDirectPage() { Status:{' '} {status} diff --git a/apps/web/src/app/vision-training/loader-test-hook-custom/page.tsx b/apps/web/src/app/vision-training/loader-test-hook-custom/page.tsx index 66e6c4d3..b0696d88 100644 --- a/apps/web/src/app/vision-training/loader-test-hook-custom/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-hook-custom/page.tsx @@ -49,9 +49,7 @@ export default function LoaderTestHookCustomPage() { gap: 4, })} > -

- Custom Hook Test -

+

Custom Hook Test

Uses custom useOpenCV hook from separate file.

@@ -82,7 +80,8 @@ export default function LoaderTestHookCustomPage() { Status:{' '} {status} diff --git a/apps/web/src/app/vision-training/loader-test-hook/page.tsx b/apps/web/src/app/vision-training/loader-test-hook/page.tsx index 49135e29..462a6593 100644 --- a/apps/web/src/app/vision-training/loader-test-hook/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-hook/page.tsx @@ -82,7 +82,8 @@ export default function LoaderTestHookPage() { Status:{' '} {status} diff --git a/apps/web/src/app/vision-training/loader-test-inline/page.tsx b/apps/web/src/app/vision-training/loader-test-inline/page.tsx index 05d8f22f..51270a19 100644 --- a/apps/web/src/app/vision-training/loader-test-inline/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-inline/page.tsx @@ -132,9 +132,7 @@ export default function LoaderTestInlinePage() { gap: 4, })} > -

- Inline Loader Test -

+

Inline Loader Test

Loader code is INLINE in this component (not imported from module).

@@ -165,7 +163,8 @@ export default function LoaderTestInlinePage() { Status:{' '} {status} diff --git a/apps/web/src/app/vision-training/loader-test-script/page.tsx b/apps/web/src/app/vision-training/loader-test-script/page.tsx index 9a3c6467..a3527dc3 100644 --- a/apps/web/src/app/vision-training/loader-test-script/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-script/page.tsx @@ -41,9 +41,7 @@ export default function LoaderTestScriptPage() { gap: 4, })} > -

- Script Tag Test (No Waiting) -

+

Script Tag Test (No Waiting)

Step 1: Add script tag. Step 2: Check if cv loaded.

diff --git a/apps/web/src/app/vision-training/loader-test-simple/page.tsx b/apps/web/src/app/vision-training/loader-test-simple/page.tsx index def9fce4..d74c510a 100644 --- a/apps/web/src/app/vision-training/loader-test-simple/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-simple/page.tsx @@ -79,7 +79,8 @@ export default function LoaderTestSimplePage() { Status:{' '} {status} diff --git a/apps/web/src/app/vision-training/loader-test-v2/page.tsx b/apps/web/src/app/vision-training/loader-test-v2/page.tsx index 0a66ddff..90fe3485 100644 --- a/apps/web/src/app/vision-training/loader-test-v2/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-v2/page.tsx @@ -43,9 +43,7 @@ export default function LoaderTestV2Page() { gap: 4, })} > -

- Loader V2 Test -

+

Loader V2 Test

Uses new loaderV2.ts with proven working pattern.

@@ -76,7 +74,8 @@ export default function LoaderTestV2Page() { Status:{' '} {status} diff --git a/apps/web/src/app/vision-training/loader-test-v3/page.tsx b/apps/web/src/app/vision-training/loader-test-v3/page.tsx index 44dc974a..e1341fde 100644 --- a/apps/web/src/app/vision-training/loader-test-v3/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-v3/page.tsx @@ -74,7 +74,8 @@ export default function LoaderTestV3Page() { Status:{' '} {status} diff --git a/apps/web/src/app/vision-training/loader-test-v4/page.tsx b/apps/web/src/app/vision-training/loader-test-v4/page.tsx index f2c64348..74c50759 100644 --- a/apps/web/src/app/vision-training/loader-test-v4/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-v4/page.tsx @@ -74,7 +74,8 @@ export default function LoaderTestV4Page() { Status:{' '} {status} diff --git a/apps/web/src/app/vision-training/loader-test-v5/page.tsx b/apps/web/src/app/vision-training/loader-test-v5/page.tsx index dc17c059..83dee107 100644 --- a/apps/web/src/app/vision-training/loader-test-v5/page.tsx +++ b/apps/web/src/app/vision-training/loader-test-v5/page.tsx @@ -44,9 +44,7 @@ export default function LoaderTestV5Page() {

Loader V5 Test (No Internal Await)

-

- Returns Promise, consumer awaits it. -

+

Returns Promise, consumer awaits it.

) : ( -
+ {/* Preprocessing Controls */} +
+
+
+ Preprocessing Controls +
+ +
+ + {/* Profile Selector */} +
+
+ Quick Profiles +
+
+ {Object.entries(DETECTION_PROFILES).map(([key, profile]) => ( + + ))} +
+ {activeProfile && DETECTION_PROFILES[activeProfile] && ( +
+ + {DETECTION_PROFILES[activeProfile].name}: + {' '} + {DETECTION_PROFILES[activeProfile].description} +
+ )} +
+ +
+ Select a profile above for quick setup, or adjust individual settings below. +
+ + {/* Strategy selector */} +
+ + +
+ {preprocessingStrategy === 'standard' && + 'Fast but needs good lighting and clear edges.'} + {preprocessingStrategy === 'enhanced' && + 'Boosts contrast first. Good for faded or washed-out images.'} + {preprocessingStrategy === 'adaptive' && + 'Handles shadows and uneven lighting. Good for documents on dark surfaces.'} + {preprocessingStrategy === 'multi' && + 'Combines all methods. Best detection but slightly slower.'} +
+
+ + {/* Toggle switches with descriptions */} +
+
+ Enhancement Options +
+
+
+ +
+ Spreads out brightness levels. Turn ON for low-contrast images. +
+
+
+ +
+ Detects edges locally. Turn ON when lighting is uneven across the document. +
+
+
+ +
+ Finds edges by dilation-erosion. Turn ON for thick or blurry edges. +
+
+
+
+ + {/* Sliders with descriptions */} +
+
+ Fine-Tuning +
+
+
+ +
+ Neighborhood size for local thresholding. Larger = smoother edges, smaller = + more detail. +
+
+
+ +
+ Threshold offset. Higher = fewer edges detected, lower = more noise. +
+
+
+ +
+ Weak edge threshold. Lower = detect faint edges (more noise). +
+
+
+ +
+ Strong edge threshold. Lower = more edges, higher = only strong edges. +
+
+
+
+ + {/* Hough Line Detection */} +
+
+ Hough Line Detection (Finger Occlusion) +
+
+ +
+ Detects straight lines and infers corners from intersections. Fallback when + contours fail. +
+
+ {enableHoughLines && ( +
+
+ +
+ Min votes. Lower = more lines. +
+
+
+ +
+ Min line segment length. +
+
+
+ +
+ Max gap to merge segments. +
+
+
+ )} +
+ + {/* Quick tips */} +
+ Tips: +
    +
  • Document not detected? Lower Canny thresholds or enable Histogram Eq.
  • +
  • Too many false edges? Raise Canny thresholds or increase Adaptive C.
  • +
  • + Fingers blocking edges? Enable Hough Lines - it finds edges by detecting straight + lines and computing their intersections. +
  • +
+
+
+ +
+ + {/* Debug polygon info */} + {debugMode && isCameraActive && cameraDebugInfo.debugPolygons.length > 0 && ( +
+
+ Candidate Polygons ({cameraDebugInfo.debugPolygons.length}): +
+ {cameraDebugInfo.debugPolygons.slice(0, 10).map((poly, i) => ( +
+ + [{poly.status}] + {' '} + vertices: {poly.vertexCount}, area: {(poly.areaRatio * 100).toFixed(1)}% + {poly.hullVertices && ` → hull: ${poly.hullVertices.length}`} + {poly.aspectRatio && ` ratio: ${poly.aspectRatio.toFixed(2)}`} +
+ ))} + {cameraDebugInfo.debugPolygons.length > 10 && ( +
+ ... and {cameraDebugInfo.debugPolygons.length - 10} more +
+ )} +
+ )}
{/* Static image section */} @@ -448,20 +1333,20 @@ function QuadTestContent() { display: 'inline-block', px: 4, py: 2, - bg: detector ? 'purple.600' : 'gray.600', + bg: isReady ? 'purple.600' : 'gray.600', color: 'white', borderRadius: 'md', - cursor: detector ? 'pointer' : 'not-allowed', - opacity: detector ? 1 : 0.5, - _hover: detector ? { bg: 'purple.500' } : {}, + cursor: isReady ? 'pointer' : 'not-allowed', + opacity: isReady ? 1 : 0.5, + _hover: isReady ? { bg: 'purple.500' } : {}, })} > - {detector ? 'Upload Image' : 'Loading...'} + {isReady ? 'Upload Image' : 'Loading...'} @@ -485,17 +1370,26 @@ function QuadTestContent() { {staticDetectionResult && ( -
+
Detection:{' '} - + {staticDetectionResult.detected ? `${staticDetectionResult.quads.length} quad${staticDetectionResult.quads.length !== 1 ? 's' : ''} found` : 'No quads found'}
- Corners: {JSON.stringify(staticDetectionResult.corners.map((c) => [Math.round(c.x), Math.round(c.y)]))} + Corners:{' '} + {JSON.stringify( + staticDetectionResult.corners.map((c) => [Math.round(c.x), Math.round(c.y)]) + )}
)} @@ -514,14 +1408,30 @@ function QuadTestContent() { })} >

- Current Architecture (Step 3: Using modular quadDetector) + Current Architecture (Step 5: useQuadDetection Hook)

    -
  • OpenCvProvider wraps the page and loads OpenCV via opencv-react
  • -
  • useOpenCv() provides the loaded state and cv instance
  • -
  • createQuadDetector(cv) creates detector from @/lib/vision/quadDetector
  • -
  • Detection now uses the modular quadDetector (no useDocumentDetection)
  • -
  • Next step: Add QuadTracker for temporal stability tracking
  • +
  • + OpenCvProvider wraps the page and loads OpenCV via opencv-react +
  • +
  • + useQuadDetection() combines OpenCV loading, detection, and tracking +
  • +
  • + Hook provides: isReady, detectInImage,{' '} + processFrame, resetTracking +
  • +
  • + detectInImage(canvas) for static images (no tracking) +
  • +
  • + processFrame(canvas) for camera feeds (with tracking) +
  • +
  • + Status: orange=tracking, yellow=stable (3+ frames), green=locked (5+ frames, high + stability) +
  • +
  • Next step: Update useDocumentDetection to use new modules
@@ -587,6 +1497,228 @@ function drawQuadOverlay( } } +/** + * Draw quad overlay with tracking status indicators + */ +function drawTrackedQuadOverlay( + canvas: HTMLCanvasElement, + frameWidth: number, + frameHeight: number, + detectedQuads: DetectedQuad[], + trackedQuad: TrackedQuad | null +): void { + canvas.width = frameWidth + canvas.height = frameHeight + + const ctx = canvas.getContext('2d') + if (!ctx) return + + ctx.clearRect(0, 0, frameWidth, frameHeight) + + // Draw all detected quads in faint color + for (const quad of detectedQuads) { + ctx.strokeStyle = 'rgba(100, 100, 100, 0.3)' + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(quad.corners[0].x, quad.corners[0].y) + for (let j = 1; j < 4; j++) { + ctx.lineTo(quad.corners[j].x, quad.corners[j].y) + } + ctx.closePath() + ctx.stroke() + } + + // Draw tracked quad with status-based color + if (trackedQuad) { + // Color based on status: locked=green, stable=yellow, tracking=orange + const color = trackedQuad.isLocked + ? 'rgba(0, 255, 100, 0.95)' + : trackedQuad.isStable + ? 'rgba(255, 220, 0, 0.9)' + : 'rgba(255, 140, 0, 0.8)' + + ctx.strokeStyle = color + ctx.lineWidth = trackedQuad.isLocked ? 5 : trackedQuad.isStable ? 4 : 3 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + + // Draw quad outline + ctx.beginPath() + ctx.moveTo(trackedQuad.corners[0].x, trackedQuad.corners[0].y) + for (let j = 1; j < 4; j++) { + ctx.lineTo(trackedQuad.corners[j].x, trackedQuad.corners[j].y) + } + ctx.closePath() + ctx.stroke() + + // Draw corner circles - size based on stability + const cornerRadius = 6 + trackedQuad.stabilityScore * 8 + ctx.fillStyle = color + for (const corner of trackedQuad.corners) { + ctx.beginPath() + ctx.arc(corner.x, corner.y, cornerRadius, 0, Math.PI * 2) + ctx.fill() + } + + // Draw stability bar at top + const barWidth = 200 + const barHeight = 8 + const barX = (frameWidth - barWidth) / 2 + const barY = 20 + + // Background + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)' + ctx.fillRect(barX, barY, barWidth, barHeight) + + // Fill based on stability + const fillWidth = barWidth * trackedQuad.stabilityScore + ctx.fillStyle = color + ctx.fillRect(barX, barY, fillWidth, barHeight) + + // Border + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)' + ctx.lineWidth = 1 + ctx.strokeRect(barX, barY, barWidth, barHeight) + + // Status text + ctx.fillStyle = 'white' + ctx.font = 'bold 14px sans-serif' + ctx.textAlign = 'center' + const statusText = trackedQuad.isLocked + ? `LOCKED (${(trackedQuad.stabilityScore * 100).toFixed(0)}%)` + : trackedQuad.isStable + ? `STABLE (${(trackedQuad.stabilityScore * 100).toFixed(0)}%)` + : `Tracking... (${trackedQuad.frameCount} frames)` + ctx.fillText(statusText, frameWidth / 2, barY + barHeight + 18) + } +} + +/** + * Draw debug overlay showing all candidate polygons with status-based colors + */ +function drawDebugOverlay( + canvas: HTMLCanvasElement, + frameWidth: number, + frameHeight: number, + quads: DetectedQuad[], + debugPolygons: DebugPolygon[] +): void { + canvas.width = frameWidth + canvas.height = frameHeight + + const ctx = canvas.getContext('2d') + if (!ctx) return + + ctx.clearRect(0, 0, frameWidth, frameHeight) + + // Color map for each status + const statusColors: Record = { + accepted: { stroke: 'rgba(0, 255, 100, 0.9)', fill: 'rgba(0, 255, 100, 0.2)' }, + too_small: { stroke: 'rgba(100, 100, 100, 0.5)', fill: 'rgba(100, 100, 100, 0.1)' }, + too_large: { stroke: 'rgba(255, 50, 50, 0.7)', fill: 'rgba(255, 50, 50, 0.1)' }, + too_few_vertices: { stroke: 'rgba(150, 150, 150, 0.5)', fill: 'rgba(150, 150, 150, 0.1)' }, + too_many_vertices: { stroke: 'rgba(255, 150, 0, 0.7)', fill: 'rgba(255, 150, 0, 0.1)' }, + bad_aspect_ratio: { stroke: 'rgba(255, 220, 0, 0.8)', fill: 'rgba(255, 220, 0, 0.1)' }, + corner_extraction_failed: { + stroke: 'rgba(255, 100, 200, 0.7)', + fill: 'rgba(255, 100, 200, 0.1)', + }, + } + + // Draw all debug polygons (except too_small which clutters the view) + for (const poly of debugPolygons) { + if (poly.status === 'too_small') continue // Skip tiny polygons + + const colors = statusColors[poly.status] + ctx.strokeStyle = colors.stroke + ctx.fillStyle = colors.fill + ctx.lineWidth = poly.status === 'accepted' ? 3 : 2 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + + // Draw polygon outline + if (poly.vertices.length > 0) { + ctx.beginPath() + ctx.moveTo(poly.vertices[0].x, poly.vertices[0].y) + for (let j = 1; j < poly.vertices.length; j++) { + ctx.lineTo(poly.vertices[j].x, poly.vertices[j].y) + } + ctx.closePath() + ctx.fill() + ctx.stroke() + + // Draw vertex count at centroid + const cx = poly.vertices.reduce((s, v) => s + v.x, 0) / poly.vertices.length + const cy = poly.vertices.reduce((s, v) => s + v.y, 0) / poly.vertices.length + + ctx.fillStyle = 'white' + ctx.font = 'bold 12px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(`${poly.vertexCount}v`, cx, cy) + } + + // Draw convex hull if available (dashed line) + if (poly.hullVertices && poly.hullVertices.length > 0) { + ctx.strokeStyle = 'rgba(0, 200, 255, 0.6)' + ctx.lineWidth = 1 + ctx.setLineDash([5, 5]) + ctx.beginPath() + ctx.moveTo(poly.hullVertices[0].x, poly.hullVertices[0].y) + for (let j = 1; j < poly.hullVertices.length; j++) { + ctx.lineTo(poly.hullVertices[j].x, poly.hullVertices[j].y) + } + ctx.closePath() + ctx.stroke() + ctx.setLineDash([]) + } + } + + // Draw accepted quads with corner markers on top + for (const quad of quads) { + ctx.strokeStyle = 'rgba(0, 255, 100, 1)' + ctx.lineWidth = 4 + ctx.beginPath() + ctx.moveTo(quad.corners[0].x, quad.corners[0].y) + for (let j = 1; j < 4; j++) { + ctx.lineTo(quad.corners[j].x, quad.corners[j].y) + } + ctx.closePath() + ctx.stroke() + + // Corner circles + ctx.fillStyle = 'rgba(0, 255, 100, 1)' + for (const corner of quad.corners) { + ctx.beginPath() + ctx.arc(corner.x, corner.y, 8, 0, Math.PI * 2) + ctx.fill() + } + } + + // Draw legend + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)' + ctx.fillRect(10, 10, 180, 130) + + ctx.font = '11px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'top' + + const legendItems: Array<{ color: string; label: string }> = [ + { color: statusColors['accepted'].stroke, label: 'Accepted (quad found)' }, + { color: statusColors['bad_aspect_ratio'].stroke, label: 'Bad aspect ratio' }, + { color: statusColors['too_many_vertices'].stroke, label: 'Too many vertices (>8)' }, + { color: statusColors['too_large'].stroke, label: 'Too large (>95%)' }, + { color: 'rgba(0, 200, 255, 0.8)', label: 'Convex hull (dashed)' }, + ] + + legendItems.forEach((item, i) => { + ctx.fillStyle = item.color + ctx.fillRect(15, 15 + i * 22, 12, 12) + ctx.fillStyle = 'white' + ctx.fillText(item.label, 32, 15 + i * 22) + }) +} + function StatusBadge({ label, status, @@ -616,3 +1748,95 @@ function StatusBadge({
) } + +function ToggleSwitch({ + label, + checked, + onChange, +}: { + label: string + checked: boolean + onChange: (checked: boolean) => void +}) { + return ( +