// ============================================ // Annotation Tool — Canvas-based Image Annotation // Phase 4: Variant Generation // ============================================ const AnnotationTool = { // Canvas references imageCanvas: null, drawingCanvas: null, imageContext: null, drawingContext: null, // State zoomLevel: 1, panOffset: { x: 0, y: 0 }, isPanning: false, panStart: { x: 0, y: 0 }, isInitialized: false, // Drawing state drawingMode: 'none', // 'none', 'pen', 'circle', 'rectangle', 'arrow' drawColor: '#ff0000', lineWidth: 3, isDrawing: false, drawStartPos: { x: 0, y: 0 }, drawingOverlay: null, // Defect simulation state defectMode: false, // When true, clicking places defects selectedDefectType: 'fiber', // 'fiber', 'scratch', 'cratch', 'particles', 'discoloration', 'bubble' isProcessing: false, // Variant generation state variants: [], // Array of { id, position, imageData } selectedVariantIndex: null, variantGridContainer: null, // Modified image state (for storing applied changes) modifiedImageData: null, // Base64 of the current modified image // Undo history history: [], maxHistorySize: 20, // Seeded random for reproducible defect generation randomSeed: 0, seededRandomCalls: 0, // UI Elements (will be set on init) elements: {}, // Defect types configuration defectTypes: [ { id: 'fiber', name: 'Fiber', icon: 'fa-grip-lines', description: 'Hair-like fibers' }, { id: 'scratch', name: 'Scratch', icon: 'fa-minus', description: 'Surface scratches' }, { id: 'crack', name: 'Crack', icon: 'fa-bolt', description: 'Crack patterns' }, { id: 'particles', name: 'Particles', icon: 'fa-dots', description: 'Dust/contamination' }, { id: 'discoloration', name: 'Discoloration', icon: 'fa-palette', description: 'Color variation' }, { id: 'bubble', name: 'Bubble', icon: 'fa-circle', description: 'Air bubbles' } ], // Variant positions (6 predefined positions) variantPositions: [ { name: 'Top-Left', xRatio: 0.2, yRatio: 0.2 }, { name: 'Top-Center', xRatio: 0.5, yRatio: 0.2 }, { name: 'Top-Right', xRatio: 0.8, yRatio: 0.2 }, { name: 'Bottom-Left', xRatio: 0.2, yRatio: 0.8 }, { name: 'Bottom-Center', xRatio: 0.5, yRatio: 0.8 }, { name: 'Bottom-Right', xRatio: 0.8, yRatio: 0.8 } ], init() { this.cacheElements(); this.setupCanvases(); this.bindEvents(); this.renderDefectTypeSelector(); this.isInitialized = true; console.log('[AnnotationTool] Phase 4 initialized: Variant generation ready'); }, cacheElements() { this.elements = { annotationPanel: '#annotation-panel', annotationContent: '#annotation-content', annotationCanvas: '#annotation-canvas', annotationDrawingCanvas: '#annotation-drawing-canvas', annotationImageWrapper: '#annotation-image-wrapper', zoomSlider: '#annotation-zoom-slider', zoomLevel: '#annotation-zoom-level', zoomIn: '#annotation-zoom-in', zoomOut: '#annotation-zoom-out', zoomReset: '#annotation-zoom-reset', btnExitAnnotation: '#btn-exit-annotation', // Drawing tools btnToolPen: '#annotation-tool-pen', btnToolCircle: '#annotation-tool-circle', btnToolRectangle: '#annotation-tool-rectangle', btnToolArrow: '#annotation-tool-arrow', btnToolEraser: '#annotation-tool-eraser', btnClearDrawing: '#annotation-clear-drawing', btnApplyDrawing: '#annotation-apply-drawing', btnUndo: '#annotation-undo', btnDrawingColor: '#annotation-drawing-color', drawingColorPalette: '#annotation-color-palette', drawingLineWidth: '#annotation-line-width', drawingLineWidthVal: '#annotation-line-width-val', drawingTools: '#annotation-drawing-tools', // Defect tools defectToolsSection: '#annotation-defect-tools', btnToggleDefectMode: '#annotation-toggle-defect-mode', defectTypeSelector: '#annotation-defect-type-selector', btnApplyDefect: '#annotation-apply-defect', btnGenerateVariants: '#annotation-generate-variants', defectPreview: '#annotation-defect-preview', // Variant tools variantToolsSection: '#annotation-variant-tools', btnSaveAllVariants: '#annotation-save-all-variants', btnClearVariants: '#annotation-clear-variants', variantGridContainer: '#annotation-variant-grid', }; }, setupCanvases() { this.imageCanvas = Helpers.$(this.elements.annotationCanvas); this.drawingCanvas = Helpers.$(this.elements.annotationDrawingCanvas); if (this.imageCanvas) { this.imageContext = this.imageCanvas.getContext('2d'); } if (this.drawingCanvas) { this.drawingContext = this.drawingCanvas.getContext('2d'); } }, bindEvents() { // Zoom controls Helpers.$(this.elements.zoomSlider)?.addEventListener('input', (e) => { this.setZoom(parseFloat(e.target.value)); }); Helpers.$(this.elements.zoomIn)?.addEventListener('click', () => { this.zoomIn(); }); Helpers.$(this.elements.zoomOut)?.addEventListener('click', () => { this.zoomOut(); }); Helpers.$(this.elements.zoomReset)?.addEventListener('click', () => { this.resetZoom(); }); // Drawing tool buttons Helpers.$(this.elements.btnToolPen)?.addEventListener('click', () => this.setTool('pen')); Helpers.$(this.elements.btnToolCircle)?.addEventListener('click', () => this.setTool('circle')); Helpers.$(this.elements.btnToolRectangle)?.addEventListener('click', () => this.setTool('rectangle')); Helpers.$(this.elements.btnToolArrow)?.addEventListener('click', () => this.setTool('arrow')); Helpers.$(this.elements.btnToolEraser)?.addEventListener('click', () => this.setTool('none')); // Defect mode toggle Helpers.$(this.elements.btnToggleDefectMode)?.addEventListener('click', () => { this.toggleDefectMode(); }); // Apply defect button Helpers.$(this.elements.btnApplyDefect)?.addEventListener('click', () => { this.applyDefect(this.selectedDefectType); }); // Generate variants button Helpers.$(this.elements.btnGenerateVariants)?.addEventListener('click', () => { this.generateDefectVariants(); }); // Save all variants button Helpers.$(this.elements.btnSaveAllVariants)?.addEventListener('click', () => { this.saveAllVariants(); }); // Clear variants button Helpers.$(this.elements.btnClearVariants)?.addEventListener('click', () => { this.clearVariants(); }); // Clear and Apply buttons Helpers.$(this.elements.btnClearDrawing)?.addEventListener('click', () => this.clearDrawing()); Helpers.$(this.elements.btnApplyDrawing)?.addEventListener('click', () => this.applyDrawingToImage()); // Undo button Helpers.$(this.elements.btnUndo)?.addEventListener('click', () => this.undo()); // Color picker Helpers.$(this.elements.btnDrawingColor)?.addEventListener('input', (e) => { this.drawColor = e.target.value; this.updateColorPaletteUI(); }); // Color palette presets this.bindColorPaletteEvents(); // Line width slider Helpers.$(this.elements.drawingLineWidth)?.addEventListener('input', (e) => { this.lineWidth = parseInt(e.target.value); this.updateLineWidthUI(); }); // Image wrapper click for defect placement Helpers.$(this.elements.annotationImageWrapper)?.addEventListener('click', (e) => { if (this.defectMode) { this.handleDefectPlacement(e); } }); // Drawing canvas events (only when not in defect mode) Helpers.$(this.elements.annotationImageWrapper)?.addEventListener('mousedown', (e) => { if (!this.defectMode) { this.handleMouseDown(e); } }); document.addEventListener('mousemove', (e) => { if (!this.defectMode && this.isDrawing) { this.handleMouseMove(e); } }); document.addEventListener('mouseup', (e) => { if (!this.defectMode && this.isDrawing) { this.handleMouseUp(e); } }); // Pan with shift+drag (only when not in drawing or defect mode) Helpers.$(this.elements.annotationImageWrapper)?.addEventListener('mousedown', (e) => { if (e.shiftKey && this.zoomLevel > 1 && !this.defectMode && this.drawingMode === 'none') { this.startPan(e); } }); document.addEventListener('mousemove', (e) => { if (this.isPanning && !this.defectMode && this.drawingMode === 'none') { this.updatePan(e); } }); document.addEventListener('mouseup', () => { if (this.isPanning) { this.endPan(); } }); // Exit annotation button Helpers.$(this.elements.btnExitAnnotation)?.addEventListener('click', () => { this.close(); }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { // Only handle shortcuts when annotation panel is open const panel = Helpers.$(this.elements.annotationPanel); if (!panel?.classList.contains('active')) return; if (e.key === 'Escape') { if (this.defectMode) { this.toggleDefectMode(); } else if (this.drawingMode !== 'none') { this.setTool('none'); } else { this.close(); } } if (e.key === 'z' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this.undo(); } }); }, // ============================================= // VARIANT GENERATION METHODS // ============================================= generateDefectVariants() { const wrapper = Helpers.$(this.elements.annotationImageWrapper); const img = wrapper?.querySelector('img'); if (!img) { Toast.show('No image loaded', 'error'); return; } if (this.isProcessing) return; this.isProcessing = true; Toast.show('Generating 6 variants...', 'info'); // Clear existing variants this.variants = []; this.selectedVariantIndex = null; // Get the original image data from the drawing canvas const originalCanvas = this.drawingCanvas; if (!originalCanvas) { this.isProcessing = false; return; } // Generate variants with defects in different positions const variantPromises = this.variantPositions.map((pos, index) => { return new Promise((resolve) => { setTimeout(() => { const variantCanvas = document.createElement('canvas'); variantCanvas.width = img.naturalWidth; variantCanvas.height = img.naturalHeight; const ctx = variantCanvas.getContext('2d'); // Draw the base image ctx.drawImage(img, 0, 0); // Draw the annotations ctx.drawImage(originalCanvas, 0, 0); // Draw the defect at the specified position const x = pos.xRatio * img.naturalWidth; const y = pos.yRatio * img.naturalHeight; this.drawDefectAtPosition(ctx, this.selectedDefectType, x, y, img.naturalWidth, img.naturalHeight); this.variants.push({ id: `variant-${Date.now()}-${index}`, position: pos.name, positionData: pos, imageData: variantCanvas.toDataURL('image/png') }); resolve(); }, 200 + (index * 150)); // Stagger the generation }); }); // Wait for all variants to be generated Promise.all(variantPromises).then(() => { this.isProcessing = false; this.renderVariantGrid(); Toast.show('Generated 6 defect variants!', 'success'); }); }, // Draw defect at specific position for variant generation drawDefectAtPosition(ctx, defectType, x, y, imgWidth, imgHeight, useSeed = true) { const regionSize = Math.min(imgWidth, imgHeight) * 0.15; switch (defectType) { case 'fiber': this.drawFiber(ctx, x, y, regionSize, useSeed); break; case 'scratch': this.drawScratch(ctx, x, y, regionSize, useSeed); break; case 'crack': this.drawCrack(ctx, x, y, regionSize, useSeed); break; case 'particles': this.drawParticles(ctx, x, y, regionSize, useSeed); break; case 'discoloration': this.drawDiscoloration(ctx, x, y, regionSize, useSeed); break; case 'bubble': this.drawBubble(ctx, x, y, regionSize, useSeed); break; } }, renderVariantGrid() { const container = Helpers.$(this.elements.variantGridContainer); if (!container) return; if (this.variants.length === 0) { container.innerHTML = `

No variants generated yet

Click "Generate Variants" to create 6 versions
`; return; } container.innerHTML = this.variants.map((variant, index) => `
Variant ${index + 1}
${variant.position} #${index + 1}
`).join(''); // Bind click events to variant cards container.querySelectorAll('.variant-card').forEach(card => { card.addEventListener('click', () => { this.selectVariant(parseInt(card.dataset.variantIndex)); }); }); }, selectVariant(index) { this.selectedVariantIndex = index; // Update UI const container = Helpers.$(this.elements.variantGridContainer); container?.querySelectorAll('.variant-card').forEach((card, i) => { card.classList.toggle('variant-card--selected', i === index); }); if (index !== null && this.variants[index]) { Toast.show(`Selected: ${this.variants[index].position}`, 'info'); } }, clearVariants() { this.variants = []; this.selectedVariantIndex = null; this.renderVariantGrid(); Toast.show('Variants cleared', 'info'); }, saveAllVariants() { if (this.variants.length === 0) { Toast.show('No variants to save', 'warning'); return; } // Show save dialog or directly save const count = this.variants.length; Toast.show(`Saved ${count} variants to dataset!`, 'success'); // For now, just show success - in real implementation this would add to gallery // You could trigger a download of all variants as a ZIP this.variants.forEach((variant, index) => { // In a full implementation, this would add to the gallery console.log(`Variant ${index + 1} (${variant.position}):`, variant.imageData.substring(0, 50) + '...'); }); }, // ============================================= // DEFECT SIMULATION METHODS // ============================================= toggleDefectMode() { this.defectMode = !this.defectMode; // Update UI const btn = Helpers.$(this.elements.btnToggleDefectMode); const wrapper = Helpers.$(this.elements.annotationImageWrapper); if (btn) { btn.classList.toggle('active', this.defectMode); btn.innerHTML = this.defectMode ? ' Exit Defect Mode' : ' Defect Mode'; } if (wrapper) { wrapper.style.cursor = this.defectMode ? 'crosshair' : 'default'; } // Show/hide defect tools section const defectSection = Helpers.$(this.elements.defectToolsSection); if (defectSection) { defectSection.classList.toggle('active', this.defectMode); } // Deselect drawing tool when entering defect mode if (this.defectMode && this.drawingMode !== 'none') { this.setTool('none'); } Toast.show(this.defectMode ? `Defect mode active - Click image to place ${this.selectedDefectType}` : 'Defect mode disabled', this.defectMode ? 'info' : 'info'); }, selectDefectType(type) { this.selectedDefectType = type; this.updateDefectTypeUI(); if (this.defectMode) { Toast.show(`Selected: ${type.charAt(0).toUpperCase() + type.slice(1)} defect`, 'info'); } }, updateDefectTypeUI() { const selector = Helpers.$(this.elements.defectTypeSelector); if (!selector) return; selector.querySelectorAll('.defect-type-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.type === this.selectedDefectType); }); // Update preview const preview = Helpers.$(this.elements.defectPreview); if (preview) { preview.innerHTML = this.getDefectPreviewHTML(this.selectedDefectType); } }, getDefectPreviewHTML(type) { const defectType = this.defectTypes.find(d => d.id === type); return `
${defectType?.name || 'Unknown'}
`; }, renderDefectTypeSelector() { const selector = Helpers.$(this.elements.defectTypeSelector); if (!selector) return; selector.innerHTML = this.defectTypes.map(type => ` `).join(''); // Bind click events selector.querySelectorAll('.defect-type-btn').forEach(btn => { btn.addEventListener('click', () => { this.selectDefectType(btn.dataset.type); }); }); this.updateDefectTypeUI(); }, handleDefectPlacement(e) { if (this.isProcessing) return; const wrapper = Helpers.$(this.elements.annotationImageWrapper); const img = wrapper?.querySelector('img'); if (!img) return; const rect = img.getBoundingClientRect(); const scaleX = img.naturalWidth / rect.width; const scaleY = img.naturalHeight / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; this.applyDefectAtPosition(this.selectedDefectType, x, y); }, applyDefectAtPosition(defectType, x, y) { this.isProcessing = true; // Save current state this.saveToHistory(); const wrapper = Helpers.$(this.elements.annotationImageWrapper); const img = wrapper?.querySelector('img'); if (!img || !this.drawingCanvas) { this.isProcessing = false; return; } // Set canvas size to match image this.drawingCanvas.width = img.naturalWidth; this.drawingCanvas.height = img.naturalHeight; const ctx = this.drawingContext; if (!ctx) { this.isProcessing = false; return; } // Draw the defect at the specified position const regionSize = Math.min(img.naturalWidth, img.naturalHeight) * 0.15; switch (defectType) { case 'fiber': this.drawFiber(ctx, x, y, regionSize); break; case 'scratch': this.drawScratch(ctx, x, y, regionSize); break; case 'crack': this.drawCrack(ctx, x, y, regionSize); break; case 'particles': this.drawParticles(ctx, x, y, regionSize); break; case 'discoloration': this.drawDiscoloration(ctx, x, y, regionSize); break; case 'bubble': this.drawBubble(ctx, x, y, regionSize); break; } this.isProcessing = false; Toast.show(`${defectType} defect placed!`, 'success'); }, // Apply defect at center of image (random position) applyDefect(defectType) { const wrapper = Helpers.$(this.elements.annotationImageWrapper); const img = wrapper?.querySelector('img'); if (!img) { Toast.show('No image loaded', 'error'); return; } // Generate random position within image bounds const margin = Math.min(img.naturalWidth, img.naturalHeight) * 0.2; const x = margin + Math.random() * (img.naturalWidth - margin * 2); const y = margin + Math.random() * (img.naturalHeight - margin * 2); this.applyDefectAtPosition(defectType, x, y); }, // Seeded random function for reproducible results seededRandom() { this.seededRandomCalls++; const x = Math.sin(this.randomSeed + this.seededRandomCalls * 9999) * 10000; return x - Math.floor(x); }, // Reset seed for variant generation resetSeed(variantIndex) { this.randomSeed = Date.now() + variantIndex * 12345; this.seededRandomCalls = 0; }, // Helper for random range using seed seededRandomRange(min, max) { return min + this.seededRandom() * (max - min); }, // Draw fiber contamination (bezier curves) drawFiber(ctx, centerX, centerY, regionSize, useSeed = false) { const fiberCount = Math.floor((useSeed ? this.seededRandomRange(2, 5) : Math.random() * 4) + 3); // Generate dynamic color based on defect type for realism const colorVariation = this.generateDefectColor(); ctx.strokeStyle = colorVariation; ctx.lineWidth = useSeed ? this.seededRandomRange(0.5, 2.5) : 1.5; ctx.lineCap = 'round'; for (let i = 0; i < fiberCount; i++) { const startX = centerX + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * regionSize * 0.5; const startY = centerY + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * regionSize * 0.5; const endX = startX + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * regionSize; const endY = startY + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * regionSize; const ctrl1X = startX + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * 50; const ctrl1Y = startY + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * 50; const ctrl2X = endX + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * 50; const ctrl2Y = endY + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * 50; ctx.globalAlpha = 0.7 + (useSeed ? this.seededRandom() : Math.random()) * 0.3; ctx.beginPath(); ctx.moveTo(startX, startY); ctx.bezierCurveTo(ctrl1X, ctrl1Y, ctrl2X, ctrl2Y, endX, endY); ctx.stroke(); } ctx.globalAlpha = 1; }, // Generate defect-specific color (darker/fiber-like colors) generateDefectColor() { const type = this.selectedDefectType; let r, g, b; switch (type) { case 'fiber': // Dark gray/black fibers r = Math.floor(this.seededRandomRange(0, 80)); g = Math.floor(this.seededRandomRange(0, 80)); b = Math.floor(this.seededRandomRange(0, 80)); break; case 'scratch': // Light gray scratches r = Math.floor(200 + this.seededRandomRange(0, 55)); g = Math.floor(200 + this.seededRandomRange(0, 55)); b = Math.floor(200 + this.seededRandomRange(0, 55)); break; case 'crack': // Dark gray cracks r = 80; g = 80; b = 80; break; case 'particles': // Either dark or light particles if (this.seededRandom() > 0.5) { r = Math.floor(this.seededRandomRange(0, 50)); g = Math.floor(this.seededRandomRange(0, 50)); b = Math.floor(this.seededRandomRange(0, 50)); } else { r = Math.floor(200 + this.seededRandomRange(0, 55)); g = Math.floor(200 + this.seededRandomRange(0, 55)); b = Math.floor(200 + this.seededRandomRange(0, 55)); } break; case 'discoloration': // Warm/brown discoloration r = Math.floor(this.seededRandomRange(100, 200)); g = Math.floor(this.seededRandomRange(80, 160)); b = Math.floor(this.seededRandomRange(60, 120)); break; case 'bubble': // White/transparent bubbles return 'rgba(255, 255, 255, 0.7)'; default: r = 100; g = 100; b = 100; } return `rgba(${r}, ${g}, ${b}, ${0.6 + this.seededRandom() * 0.3})`; }, // Draw surface scratch (lines) drawScratch(ctx, centerX, centerY, regionSize, useSeed = false) { const angle = (useSeed ? this.seededRandom() : Math.random()) * Math.PI * 2; const length = regionSize * (0.8 + (useSeed ? this.seededRandom() : Math.random()) * 0.4); const colorVariation = this.generateDefectColor(); ctx.strokeStyle = colorVariation; ctx.lineWidth = useSeed ? this.seededRandomRange(0.5, 2) : 1 + Math.random(); ctx.lineCap = 'round'; ctx.globalAlpha = 0.8; // Main scratch line const startX = centerX - Math.cos(angle) * length / 2; const startY = centerY - Math.sin(angle) * length / 2; const endX = centerX + Math.cos(angle) * length / 2; const endY = centerY + Math.sin(angle) * length / 2; ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(endX, endY); ctx.stroke(); // Add some branching for realism if ((useSeed ? this.seededRandom() : Math.random()) > 0.5) { const branchStart = { x: startX + (endX - startX) * (0.3 + (useSeed ? this.seededRandom() : Math.random()) * 0.4), y: startY + (endY - startY) * (0.3 + (useSeed ? this.seededRandom() : Math.random()) * 0.4) }; const branchAngle = angle + ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * 1.2; const branchLength = length * 0.3; ctx.beginPath(); ctx.moveTo(branchStart.x, branchStart.y); ctx.lineTo( branchStart.x + Math.cos(branchAngle) * branchLength, branchStart.y + Math.sin(branchAngle) * branchLength ); ctx.stroke(); } ctx.globalAlpha = 1; }, // Draw crack pattern (branching random walk) drawCrack(ctx, centerX, centerY, regionSize, useSeed = false) { const steps = 10 + Math.floor((useSeed ? this.seededRandom() : Math.random()) * 10); const stepSize = regionSize / steps; ctx.strokeStyle = 'rgba(80, 80, 80, 0.8)'; ctx.lineWidth = 1.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.globalAlpha = 0.85; let x = centerX; let y = centerY; let angle = (useSeed ? this.seededRandom() : Math.random()) * Math.PI * 2; // Main crack line with slight wandering ctx.beginPath(); ctx.moveTo(x, y); for (let i = 0; i < steps; i++) { // Add some randomness to the direction angle += ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * 0.8; const segLength = (useSeed ? this.seededRandomRange(10, 30) : 10 + Math.random() * 20); x += Math.cos(angle) * segLength; y += Math.sin(angle) * segLength; ctx.lineTo(x, y); // Branch at random points if ((useSeed ? this.seededRandom() : Math.random()) > 0.7 && i > 2 && i < steps - 2) { const branchAngle = (useSeed ? this.seededRandom() : Math.random()) * Math.PI * 2; const branchSegCount = 3 + Math.floor((useSeed ? this.seededRandom() : Math.random()) * 3); const branchX = x; const branchY = y; for (let j = 0; j < branchSegCount; j++) { const branchSegAngle = (useSeed ? this.seededRandom() : Math.random()) * Math.PI * 2; const branchSegLen = (useSeed ? this.seededRandomRange(5, 20) : 5 + Math.random() * 15); ctx.lineTo( branchX + Math.cos(branchSegAngle) * branchSegLen, branchY + Math.sin(branchSegAngle) * branchSegLen ); ctx.moveTo(branchX, branchY); } } } ctx.stroke(); ctx.globalAlpha = 1; }, // Draw dust/particles (random circles) drawParticles(ctx, centerX, centerY, regionSize, useSeed = false) { const particleCount = Math.floor((useSeed ? this.seededRandomRange(8, 23) : 8 + Math.random() * 15)); for (let i = 0; i < particleCount; i++) { const x = centerX + ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * regionSize; const y = centerY + ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * regionSize; const radius = (useSeed ? this.seededRandomRange(1, 4) : 1 + Math.random() * 3); const isDark = (useSeed ? this.seededRandom() : Math.random()) > 0.5; const baseColor = isDark ? `rgba(${Math.floor((useSeed ? this.seededRandom() : Math.random()) * 50)}, ${Math.floor((useSeed ? this.seededRandom() : Math.random()) * 50)}, ${Math.floor((useSeed ? this.seededRandom() : Math.random()) * 50)}, ${0.5 + (useSeed ? this.seededRandom() : Math.random()) * 0.4})` : `rgba(${200 + Math.floor((useSeed ? this.seededRandom() : Math.random()) * 55)}, ${200 + Math.floor((useSeed ? this.seededRandom() : Math.random()) * 55)}, ${200 + Math.floor((useSeed ? this.seededRandom() : Math.random()) * 55)}, ${0.5 + (useSeed ? this.seededRandom() : Math.random()) * 0.4})`; if ((useSeed ? this.seededRandom() : Math.random()) > 0.6) { ctx.fillStyle = baseColor; ctx.globalAlpha = 1; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); } else { ctx.strokeStyle = baseColor; ctx.lineWidth = 1; ctx.globalAlpha = 1; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.stroke(); } } ctx.globalAlpha = 1; }, // Draw discoloration (radial gradients) drawDiscoloration(ctx, centerX, centerY, regionSize, useSeed = false) { const radius = regionSize * 0.5; // Generate discoloration colors const colorR = Math.floor((useSeed ? this.seededRandomRange(100, 200) : 100 + Math.random() * 100)); const colorG = Math.floor((useSeed ? this.seededRandomRange(80, 160) : 80 + Math.random() * 80)); const colorB = Math.floor((useSeed ? this.seededRandomRange(60, 120) : 60 + Math.random() * 60)); // Create gradient with warm discoloration colors const gradient = ctx.createRadialGradient( centerX, centerY, 0, centerX, centerY, radius ); gradient.addColorStop(0, `rgba(${colorR}, ${colorG}, ${colorB}, 0.4)`); gradient.addColorStop(0.5, `rgba(${colorR}, ${colorG}, ${colorB}, 0.2)`); gradient.addColorStop(1, `rgba(${colorR}, ${colorG}, ${colorB}, 0)`); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); ctx.fill(); }, // Draw air bubble (gradient circle with outline) drawBubble(ctx, centerX, centerY, regionSize, useSeed = false) { const bubbleCount = Math.floor((useSeed ? this.seededRandomRange(1, 4) : 1 + Math.random() * 3)); for (let i = 0; i < bubbleCount; i++) { const x = centerX + ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * regionSize * 0.3; const y = centerY + ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * regionSize * 0.3; const radius = regionSize * 0.15 + (useSeed ? this.seededRandomRange(0, regionSize * 0.1) : Math.random() * regionSize * 0.1); // Bubble outline - light gray/white const outlineAlpha = 0.5 + (useSeed ? this.seededRandom() : Math.random()) * 0.4; ctx.strokeStyle = `rgba(255, 255, 255, ${outlineAlpha})`; ctx.lineWidth = 1 + (useSeed ? this.seededRandom() : Math.random()) * 0.5; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.stroke(); // Bubble highlight gradient const gradient = ctx.createRadialGradient( x - radius * 0.3, y - radius * 0.3, 0, x, y, radius ); gradient.addColorStop(0, `rgba(255, 255, 255, ${0.3 + (useSeed ? this.seededRandom() : Math.random()) * 0.2})`); gradient.addColorStop(0.7, `rgba(255, 255, 255, ${0.1 + (useSeed ? this.seededRandom() : Math.random()) * 0.1})`); gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); } }, // Helper to adjust color alpha adjustColorAlpha(hexColor, alpha) { // Convert hex to RGB const r = parseInt(hexColor.slice(1, 3), 16); const g = parseInt(hexColor.slice(3, 5), 16); const b = parseInt(hexColor.slice(5, 7), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }, // ============================================= // DRAWING METHODS // ============================================= setTool(tool) { // Exit defect mode when selecting a drawing tool if (this.defectMode && tool !== 'none') { this.toggleDefectMode(); } const tools = ['pen', 'circle', 'rectangle', 'arrow']; // Clear active state from all tool buttons ['btnToolPen', 'btnToolCircle', 'btnToolRectangle', 'btnToolArrow', 'btnToolEraser'].forEach(btnId => { const btn = Helpers.$(this.elements[btnId]); if (btn) btn.classList.remove('active'); }); if (tool === 'none') { this.drawingMode = 'none'; this.isDrawing = false; const eraserBtn = Helpers.$(this.elements.btnToolEraser); if (eraserBtn) eraserBtn.classList.add('active'); } else if (tools.includes(tool)) { this.drawingMode = tool; this.clearDrawing(); const btnId = `btnTool${tool.charAt(0).toUpperCase() + tool.slice(1)}`; const btn = Helpers.$(this.elements[btnId]); if (btn) btn.classList.add('active'); } // Update cursor const wrapper = Helpers.$(this.elements.annotationImageWrapper); if (wrapper) { wrapper.style.cursor = tool !== 'none' ? 'crosshair' : 'default'; } Toast.show(`${tool === 'none' ? 'Drawing' : tool.charAt(0).toUpperCase() + tool.slice(1)} mode ${tool === 'none' ? 'disabled' : 'active'}`, 'info'); }, bindColorPaletteEvents() { const palette = Helpers.$(this.elements.drawingColorPalette); if (!palette) return; const colors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', '#ffffff', '#000000']; palette.innerHTML = ''; colors.forEach(color => { const btn = document.createElement('button'); btn.className = 'annotation-color-btn'; btn.style.backgroundColor = color; btn.dataset.color = color; btn.title = color; btn.addEventListener('click', () => { this.drawColor = color; this.updateColorPaletteUI(); // Update custom color input const colorInput = Helpers.$(this.elements.btnDrawingColor); if (colorInput) colorInput.value = color; }); palette.appendChild(btn); }); this.updateColorPaletteUI(); }, updateColorPaletteUI() { const palette = Helpers.$(this.elements.drawingColorPalette); if (!palette) return; palette.querySelectorAll('.annotation-color-btn').forEach(btn => { if (btn.dataset.color === this.drawColor) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); }, updateLineWidthUI() { const display = Helpers.$(this.elements.drawingLineWidthVal); const slider = Helpers.$(this.elements.drawingLineWidth); if (display) display.textContent = `${this.lineWidth}px`; if (slider) slider.value = this.lineWidth; }, handleMouseDown(e) { // Only handle if in drawing mode if (this.drawingMode === 'none') return; if (this.isPanning) return; const wrapper = Helpers.$(this.elements.annotationImageWrapper); const img = wrapper?.querySelector('img'); if (!img || !this.drawingCanvas) return; // Calculate position relative to image const rect = img.getBoundingClientRect(); const scaleX = img.naturalWidth / rect.width; const scaleY = img.naturalHeight / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; this.isDrawing = true; this.drawStartPos = { x, y }; const ctx = this.drawingContext; if (!ctx) return; ctx.strokeStyle = this.drawColor; ctx.lineWidth = this.lineWidth * scaleX; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; if (this.drawingMode === 'pen') { ctx.beginPath(); ctx.moveTo(x, y); } }, handleMouseMove(e) { if (!this.isDrawing || this.drawingMode === 'none') return; const wrapper = Helpers.$(this.elements.annotationImageWrapper); const img = wrapper?.querySelector('img'); if (!img || !this.drawingContext) return; const rect = img.getBoundingClientRect(); const scaleX = img.naturalWidth / rect.width; const scaleY = img.naturalHeight / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; const ctx = this.drawingContext; if (this.drawingMode === 'pen') { ctx.lineTo(x, y); ctx.stroke(); } }, handleMouseUp(e) { if (!this.isDrawing) return; this.isDrawing = false; const wrapper = Helpers.$(this.elements.annotationImageWrapper); const img = wrapper?.querySelector('img'); if (!img || !this.drawingContext) return; const rect = img.getBoundingClientRect(); const scaleX = img.naturalWidth / rect.width; const scaleY = img.naturalHeight / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; const ctx = this.drawingContext; if (this.drawingMode === 'circle') { const radius = Math.sqrt( Math.pow(x - this.drawStartPos.x, 2) + Math.pow(y - this.drawStartPos.y, 2) ); ctx.beginPath(); ctx.arc(this.drawStartPos.x, this.drawStartPos.y, radius, 0, Math.PI * 2); ctx.stroke(); } else if (this.drawingMode === 'rectangle') { const width = x - this.drawStartPos.x; const height = y - this.drawStartPos.y; ctx.strokeRect(this.drawStartPos.x, this.drawStartPos.y, width, height); } else if (this.drawingMode === 'arrow') { // Draw arrow const headlen = 15 * scaleX; const angle = Math.atan2(y - this.drawStartPos.y, x - this.drawStartPos.x); ctx.beginPath(); ctx.moveTo(this.drawStartPos.x, this.drawStartPos.y); ctx.lineTo(x, y); ctx.stroke(); // Draw arrowhead ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo( x - headlen * Math.cos(angle - Math.PI / 6), y - headlen * Math.sin(angle - Math.PI / 6) ); ctx.moveTo(x, y); ctx.lineTo( x - headlen * Math.cos(angle + Math.PI / 6), y - headlen * Math.sin(angle + Math.PI / 6) ); ctx.stroke(); } // Save state for undo this.saveToHistory(); }, saveToHistory() { if (!this.drawingCanvas) return; const dataUrl = this.drawingCanvas.toDataURL('image/png'); this.history.push(dataUrl); if (this.history.length > this.maxHistorySize) { this.history.shift(); } }, clearDrawing() { if (!this.drawingContext || !this.drawingCanvas) return; this.drawingContext.clearRect(0, 0, this.drawingCanvas.width, this.drawingCanvas.height); Toast.show('Drawing cleared', 'info'); }, applyDrawingToImage() { Toast.show('Annotations applied! (Save functionality coming)', 'success'); }, undo() { if (this.history.length === 0) { Toast.show('Nothing to undo', 'warning'); return; } const previousState = this.history.pop(); const img = new Image(); img.onload = () => { if (!this.drawingContext || !this.drawingCanvas) return; this.drawingCanvas.width = img.width; this.drawingCanvas.height = img.height; this.drawingContext.drawImage(img, 0, 0); Toast.show('Undone', 'info'); }; img.src = previousState; }, // ============================================= // ZOOM/PAN METHODS // ============================================= setZoom(level) { this.zoomLevel = Math.max(0.5, Math.min(4, level)); this.updateZoomUI(); this.applyTransform(); }, zoomIn() { this.setZoom(this.zoomLevel + 0.25); }, zoomOut() { this.setZoom(this.zoomLevel - 0.25); }, resetZoom() { this.zoomLevel = 1; this.panOffset = { x: 0, y: 0 }; this.updateZoomUI(); this.applyTransform(); }, updateZoomUI() { const slider = Helpers.$(this.elements.zoomSlider); const levelDisplay = Helpers.$(this.elements.zoomLevel); if (slider) { slider.value = this.zoomLevel; } if (levelDisplay) { levelDisplay.textContent = `${Math.round(this.zoomLevel * 100)}%`; } }, applyTransform() { const wrapper = Helpers.$(this.elements.annotationImageWrapper); if (!wrapper) return; wrapper.style.transform = `scale(${this.zoomLevel}) translate(${this.panOffset.x / this.zoomLevel}px, ${this.panOffset.y / this.zoomLevel}px)`; }, startPan(e) { this.isPanning = true; this.panStart = { x: e.clientX - this.panOffset.x, y: e.clientY - this.panOffset.y }; Helpers.$(this.elements.annotationImageWrapper).style.cursor = 'grabbing'; }, updatePan(e) { if (!this.isPanning) return; this.panOffset = { x: e.clientX - this.panStart.x, y: e.clientY - this.panStart.y }; this.applyTransform(); }, endPan() { this.isPanning = false; const wrapper = Helpers.$(this.elements.annotationImageWrapper); if (wrapper) { wrapper.style.cursor = this.zoomLevel > 1 ? 'grab' : 'default'; } }, // ============================================= // PANEL METHODS // ============================================= // Show annotation panel with image open(imageData) { const panel = Helpers.$(this.elements.annotationPanel); const content = Helpers.$(this.elements.annotationContent); if (!panel || !content) { console.error('[AnnotationTool] Panel elements not found'); return; } // Reset state this.resetZoom(); this.history = []; this.variants = []; this.selectedVariantIndex = null; this.setTool('none'); this.defectMode = false; // Set image if (imageData && content) { // Create or update image element let img = content.querySelector('img'); if (!img) { img = document.createElement('img'); img.id = 'annotation-image'; img.className = 'annotation-image'; img.draggable = false; content.appendChild(img); } img.src = imageData; // Wait for image to load to set up canvas dimensions img.onload = () => { if (this.drawingCanvas) { this.drawingCanvas.width = img.naturalWidth; this.drawingCanvas.height = img.naturalHeight; } if (this.imageCanvas) { this.imageCanvas.width = img.naturalWidth; this.imageCanvas.height = img.naturalHeight; } }; } // Show panel panel.classList.add('active'); // Initialize canvases if needed this.setupCanvases(); this.updateColorPaletteUI(); this.updateLineWidthUI(); // Update defect mode UI const btn = Helpers.$(this.elements.btnToggleDefectMode); if (btn) { btn.classList.remove('active'); btn.innerHTML = ' Defect Mode'; } this.updateDefectTypeUI(); this.renderVariantGrid(); Toast.show('Annotation tool opened', 'info'); }, // Close annotation panel close() { const panel = Helpers.$(this.elements.annotationPanel); if (panel) { panel.classList.remove('active'); } this.setTool('none'); this.defectMode = false; Toast.show('Annotation tool closed', 'info'); }, // ============================================= // TEST METHODS // ============================================= getState() { return { zoomLevel: this.zoomLevel, panOffset: { ...this.panOffset }, isPanning: this.isPanning, isInitialized: this.isInitialized, drawingMode: this.drawingMode, defectMode: this.defectMode, selectedDefectType: this.selectedDefectType, drawColor: this.drawColor, lineWidth: this.lineWidth, historySize: this.history.length, variantsCount: this.variants.length, selectedVariantIndex: this.selectedVariantIndex }; }, validateCanvases() { const results = { imageCanvas: !!this.imageCanvas, drawingCanvas: !!this.drawingCanvas, imageContext: !!this.imageContext, drawingContext: !!this.drawingContext, allCanvasesReady: !!(this.imageCanvas && this.drawingCanvas && this.imageContext && this.drawingContext) }; console.log('[AnnotationTool] Canvas validation:', results); return results; } }; // Make available globally window.AnnotationTool = AnnotationTool; // Auto-init when DOM is ready document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { AnnotationTool.init(); }, 100); });