(function () { let currentImageUrl = 'https://raw.githubusercontent.com/mxgmn/WaveFunctionCollapse/master/samples/Flowers.png'; let currentPatternSize = 3; let enableRotations = false; let OUTPUT_SIZE = 64; const SOURCE_VISUAL_SCALE = 4; const imageUrlInput = document.getElementById('imageUrlInput'); const patternSizeSelect = document.getElementById('patternSizeSelect'); const randomSelectionCheckbox = document.getElementById( 'randomSelectionCheckbox' ); const enableRotationsCheckbox = document.getElementById( 'enableRotationsCheckbox' ); const restartButton = document.getElementById('restartButton'); const sourceCanvas = document.getElementById('sourceCanvas'); const sourceCtx = sourceCanvas.getContext('2d'); const outputCanvas = document.getElementById('outputCanvas'); const outputCtx = outputCanvas.getContext('2d'); outputCtx.imageSmoothingEnabled = false; const statusEl = document.getElementById('status'); let statsContainer = document.getElementById('statsContainer'); let patterns, patternByID, patternByIndex, patternIdToIndex; let adjacency; let sampleWidth, sampleHeight, srcData; let pCols, pRows, grid; let phase = 'idle'; let queue = [], queuedCoordinatesSet = new Set(), maxTries; let currentAttemptFailed = false; let dirtyTilesForFrame = new Set(); let animationFrameId = null; let backtrackStack = []; const MAX_BACKTRACK_DEPTH = 4096; // --- Stats Object --- let stats = {}; function resetStats() { stats = { sourceImage: { loaded: false, width: 0, height: 0, pixels: 0 }, patternExtraction: { rawPatternCount: 0, uniquePatternCount: 0, rotationsEnabled: false, timeMs: 0, }, adjacency: { totalRules: 0, timeMs: 0, }, grid: { pCols: 0, pRows: 0, initialCellOptions: 0, timeMs: 0, }, wfcRun: { maxBacktrackStackDepthReached: 0, totalBacktracks: 0, totalContradictions: 0, forcedBacktracksByDepth: 0, cellsCollapsed: 0, propagationSteps: 0, startTime: 0, endTime: 0, durationMs: 0, status: 'Not started', }, memory: { patternsArrayBytes: 0, patternCellsBytes: 0, patternByIdBytes: 0, adjacencyBytes: 0, estimatedGridSnapshotBytes: 0, backtrackStackPeakBytes: 0, }, }; } function formatBytes(bytes, decimals = 2) { if (!Number.isFinite(bytes) || bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; // Prevent log(0) or log(negative) for Math.abs(bytes) if (Math.abs(bytes) < 1) return bytes.toFixed(dm) + ' Bytes'; const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } function estimateStringBytes(str) { return str ? str.length * 2 : 0; // UTF-16 } function estimateObjectOverhead(obj) { if (!obj) return 0; // Rough estimate: (key string + pointer + type tag) per property + base object overhead return ( Object.keys(obj).length * (estimateStringBytes('key_placeholder_avg') + 8 + 4) + 16 ); } function renderStats() { if (!statsContainer) return; let html = `WFC Statistics:
`; html += `Status: ${stats.wfcRun.status}
`; if (stats.wfcRun.durationMs > 0) { html += `Total Time: ${(stats.wfcRun.durationMs / 1000).toFixed( 2 )}s
`; } html += `
`; if (stats.sourceImage.loaded) { html += `Source Image:
`; html += ` Dimensions: ${stats.sourceImage.width}x${stats.sourceImage.height}
`; html += ` Pattern Size: ${currentPatternSize}x${currentPatternSize}
`; html += ` Rotations Enabled: ${stats.patternExtraction.rotationsEnabled}
`; html += `
`; html += `Pattern Extraction (${( stats.patternExtraction.timeMs / 1000 ).toFixed(2)}s):
`; html += ` Raw Overlapping Patterns: ${stats.patternExtraction.rawPatternCount}
`; html += ` Unique Patterns: ${stats.patternExtraction.uniquePatternCount}
`; html += ` Memory (Pattern Objects): ~${formatBytes( stats.memory.patternsArrayBytes )}
`; html += ` Memory (Pattern Pixel Data): ~${formatBytes( stats.memory.patternCellsBytes )}
`; html += ` Memory (Pattern ID Map): ~${formatBytes( stats.memory.patternByIdBytes )}
`; html += `
`; html += `Adjacency Rules (${( stats.adjacency.timeMs / 1000 ).toFixed(2)}s):
`; html += ` Total Adjacency Rules: ${stats.adjacency.totalRules}
`; html += ` Memory (Adjacency Map): ~${formatBytes( stats.memory.adjacencyBytes )}
`; html += `
`; html += `Output Grid (${(stats.grid.timeMs / 1000).toFixed( 2 )}s initialization):
`; html += ` Output Grid Pixel Size: ${OUTPUT_SIZE}x${OUTPUT_SIZE}
`; html += ` Pattern Grid Dimensions: ${stats.grid.pCols}x${ stats.grid.pRows } (${stats.grid.pCols * stats.grid.pRows} cells)
`; html += ` Initial Options/Cell: ${stats.grid.initialCellOptions}
`; html += ` Est. Memory per Grid State: ~${formatBytes( stats.memory.estimatedGridSnapshotBytes )}
`; html += `
`; } html += `WFC Run Dynamics:
`; html += ` Max Backtrack Stack Depth: ${stats.wfcRun.maxBacktrackStackDepthReached} / ${MAX_BACKTRACK_DEPTH}
`; html += ` Est. Peak Backtrack Stack Memory: ~${formatBytes( stats.memory.backtrackStackPeakBytes )}
`; html += ` Total Backtracks: ${stats.wfcRun.totalBacktracks}
`; html += ` Forced Backtracks (Max Depth): ${stats.wfcRun.forcedBacktracksByDepth}
`; html += ` Total Contradictions: ${stats.wfcRun.totalContradictions}
`; html += ` Cells Collapsed: ${stats.wfcRun.cellsCollapsed}
`; html += ` Propagation Steps: ${stats.wfcRun.propagationSteps}
`; statsContainer.innerHTML = html; // Use innerHTML to render strong tags } imageUrlInput.value = currentImageUrl; patternSizeSelect.value = currentPatternSize.toString(); enableRotationsCheckbox.checked = enableRotations; function Uint8ArrayToHexString(u8a) { let hex = ''; for (let i = 0; i < u8a.length; i++) { let h = u8a[i].toString(16); if (h.length < 2) { h = '0' + h; } hex += h; } return hex; } function deepCopyGrid(gridToCopy) { if (!gridToCopy) return null; return gridToCopy.map((row) => row.map((cellOptions) => [...cellOptions])); } function deepCopyQueue(queueToCopy) { if (!queueToCopy) return null; return queueToCopy.map((item) => (Array.isArray(item) ? [...item] : item)); } const img = new Image(); img.crossOrigin = 'Anonymous'; img.onload = () => { if (phase === 'loading_image_error') return; phase = 'processing'; stats.wfcRun.status = 'Processing Image'; renderStats(); sampleWidth = img.width; sampleHeight = img.height; stats.sourceImage = { loaded: true, width: sampleWidth, height: sampleHeight, pixels: sampleWidth * sampleHeight, }; sourceCanvas.width = sampleWidth; sourceCanvas.height = sampleHeight; sourceCtx.drawImage(img, 0, 0); const dynamicScale = Math.max( 1, Math.min( SOURCE_VISUAL_SCALE, Math.floor(256 / Math.max(sampleWidth, sampleHeight)) ) ); sourceCanvas.style.width = sampleWidth * dynamicScale + 'px'; sourceCanvas.style.height = sampleHeight * dynamicScale + 'px'; if (sampleWidth < currentPatternSize || sampleHeight < currentPatternSize) { statusEl.textContent = `Error: Image dimensions (${sampleWidth}x${sampleHeight}) are smaller than pattern size (${currentPatternSize}x${currentPatternSize}).`; phase = 'error'; stats.wfcRun.status = 'Error: Image too small'; renderStats(); return; } srcData = sourceCtx.getImageData(0, 0, sampleWidth, sampleHeight).data; statusEl.textContent = `Extracting ${currentPatternSize}x${currentPatternSize} patterns...`; stats.wfcRun.status = `Extracting patterns...`; renderStats(); requestAnimationFrame(() => { // Use timeout/rAF to allow UI update for status const t0 = performance.now(); try { extractPatterns(); stats.patternExtraction.timeMs = performance.now() - t0; if (!patterns || patterns.length === 0) { statusEl.textContent = `Error: No patterns found. Make sure image is suitable. Check rotations if enabled.`; phase = 'error'; stats.wfcRun.status = 'Error: No patterns'; renderStats(); return; } stats.patternExtraction.uniquePatternCount = patterns.length; stats.patternExtraction.rotationsEnabled = enableRotations; stats.memory.patternCellsBytes = 0; stats.memory.patternsArrayBytes = 0; // For the 'patterns' array itself and objects within patterns.forEach((p) => { stats.memory.patternCellsBytes += p.cells.byteLength; // Actual pixel data // Estimate size of pattern object (id string, freq num, index num, cells ref, object shell) stats.memory.patternsArrayBytes += estimateStringBytes(p.id) + 4 + 4 + 8 + 16; }); stats.memory.patternsArrayBytes += 16; // Base array overhead for 'patterns' stats.memory.patternByIdBytes = estimateObjectOverhead(patternByID); Object.keys(patternByID).forEach((key) => { stats.memory.patternByIdBytes += estimateStringBytes(key) + 8; // key + reference }); let statusMessage = `Found ${patterns.length} unique patterns.`; if (patterns.length > 1000) { statusMessage += ` (High count: ${patterns.length}, may be memory intensive or slow). Building adjacency...`; } else { statusMessage += ` Building adjacency...`; } statusEl.textContent = statusMessage; stats.wfcRun.status = `Building adjacency...`; renderStats(); requestAnimationFrame(() => { const t1 = performance.now(); try { buildAdjacency(); stats.adjacency.timeMs = performance.now() - t1; statusEl.textContent = 'Initializing WFC grid...'; stats.wfcRun.status = `Initializing grid...`; renderStats(); requestAnimationFrame(() => { const t2 = performance.now(); try { initializeGrid(); // This sets outputCanvas dimensions stats.grid.timeMs = performance.now() - t2; if (phase === 'error') { renderStats(); return; } statusEl.textContent = 'Running progressive WFC...'; stats.wfcRun.status = 'Running WFC...'; stats.wfcRun.startTime = performance.now(); renderStats(); phase = 'collapse'; if (animationFrameId) cancelAnimationFrame(animationFrameId); animationFrameId = requestAnimationFrame(runStep); } catch (e) { console.error('Error during grid initialization:', e); statusEl.textContent = `Error initializing grid: ${e.message}`; phase = 'error'; stats.wfcRun.status = `Error: Grid init failed (${e.message})`; renderStats(); } }); } catch (e) { console.error('Error during adjacency building:', e); statusEl.textContent = `Error building adjacency: ${e.message}`; phase = 'error'; stats.wfcRun.status = `Error: Adjacency failed (${e.message})`; renderStats(); } }); } catch (e) { console.error('Error during pattern extraction:', e); statusEl.textContent = `Error extracting patterns: ${e.message}`; phase = 'error'; stats.wfcRun.status = `Error: Pattern extraction failed (${e.message})`; renderStats(); } }); }; img.onerror = () => { statusEl.textContent = 'Error: Failed to load source image. Check URL and CORS policy. Try a different image host.'; phase = 'loading_image_error'; stats.wfcRun.status = 'Error: Image load failed'; stats.sourceImage.loaded = false; renderStats(); }; function resetWfcStateAndLoadNewImage() { if (animationFrameId) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } phase = 'loading'; resetStats(); stats.wfcRun.status = 'Loading image...'; renderStats(); if (outputCanvas.width > 0 && outputCanvas.height > 0) { // Ensure canvas has dimensions before clearing outputCtx.clearRect(0, 0, outputCanvas.width, outputCanvas.height); } currentImageUrl = imageUrlInput.value.trim(); currentPatternSize = parseInt(patternSizeSelect.value, 10); enableRotations = enableRotationsCheckbox.checked; patterns = null; patternByID = null; patternByIndex = null; patternIdToIndex = null; adjacency = null; grid = null; queue = []; queuedCoordinatesSet.clear(); currentAttemptFailed = false; maxTries = 0; dirtyTilesForFrame.clear(); pCols = 0; pRows = 0; srcData = null; backtrackStack = []; if (!currentImageUrl) { statusEl.textContent = 'Error: Image URL cannot be empty.'; phase = 'error'; stats.wfcRun.status = 'Error: No image URL'; renderStats(); return; } img.src = ''; img.src = currentImageUrl; } restartButton.addEventListener('click', resetWfcStateAndLoadNewImage); function rotatePatternCells(flatCells, pSize, angle) { const newFlatCells = new Uint8Array(pSize * pSize * 4); if (angle === 0) { newFlatCells.set(flatCells); return newFlatCells; } for (let r_orig = 0; r_orig < pSize; r_orig++) { for (let c_orig = 0; c_orig < pSize; c_orig++) { const oldIdxBase = (r_orig * pSize + c_orig) * 4; let nr, nc; if (angle === 90) { nr = c_orig; nc = pSize - 1 - r_orig; } else if (angle === 180) { nr = pSize - 1 - r_orig; nc = pSize - 1 - c_orig; } else if (angle === 270) { nr = pSize - 1 - c_orig; nc = r_orig; } else { newFlatCells.set(flatCells); return newFlatCells; } const newIdxBase = (nr * pSize + nc) * 4; newFlatCells[newIdxBase] = flatCells[oldIdxBase]; newFlatCells[newIdxBase + 1] = flatCells[oldIdxBase + 1]; newFlatCells[newIdxBase + 2] = flatCells[oldIdxBase + 2]; newFlatCells[newIdxBase + 3] = flatCells[oldIdxBase + 3]; } } return newFlatCells; } function extractPatterns() { const countMap = new Map(); const keyToFlatCells = new Map(); const pSize = currentPatternSize; let rawPatternCounter = 0; for (let y = 0; y <= sampleHeight - pSize; y++) { for (let x = 0; x <= sampleWidth - pSize; x++) { rawPatternCounter++; const originalFlatBlock = new Uint8Array(pSize * pSize * 4); let k = 0; for (let dy = 0; dy < pSize; dy++) { for (let dx = 0; dx < pSize; dx++) { const imgPixelY = y + dy; const imgPixelX = x + dx; const srcIdx = (imgPixelY * sampleWidth + imgPixelX) * 4; originalFlatBlock[k++] = srcData[srcIdx]; originalFlatBlock[k++] = srcData[srcIdx + 1]; originalFlatBlock[k++] = srcData[srcIdx + 2]; originalFlatBlock[k++] = srcData[srcIdx + 3]; } } const rotationsToConsider = enableRotations ? [0, 90, 180, 270] : [0]; for (const angle of rotationsToConsider) { const currentFlatBlockCells = rotatePatternCells( originalFlatBlock, pSize, angle ); const key = Uint8ArrayToHexString(currentFlatBlockCells); countMap.set(key, (countMap.get(key) || 0) + 1); if (!keyToFlatCells.has(key)) { keyToFlatCells.set(key, currentFlatBlockCells); } } } } stats.patternExtraction.rawPatternCount = rawPatternCounter * (enableRotations ? 4 : 1); patterns = []; patternByID = {}; patternByIndex = []; patternIdToIndex = {}; let idx = 0; for (const [key, freq] of countMap.entries()) { const patID = 'p' + idx; const flatCellsData = keyToFlatCells.get(key); const patObj = { id: patID, cells: flatCellsData, frequency: freq, index: idx, }; patterns.push(patObj); patternByID[patID] = patObj; patternByIndex[idx] = patObj; patternIdToIndex[patID] = idx; idx++; } } function buildAdjacency() { adjacency = {}; if (!patterns) return; const pSize = currentPatternSize; let ruleCount = 0; for (const pat of patterns) { adjacency[pat.id] = { up: [], down: [], left: [], right: [] }; } for (const p of patterns) { for (const q of patterns) { if (p.id === q.id && patterns.length === 1) { adjacency[p.id].right.push(q.index); ruleCount++; adjacency[q.id].left.push(p.index); ruleCount++; adjacency[p.id].down.push(q.index); ruleCount++; adjacency[q.id].up.push(p.index); ruleCount++; continue; } let canSitRight = true; if (pSize > 1) { for (let r = 0; r < pSize; r++) { for (let c = 0; c < pSize - 1; c++) { for (let ch = 0; ch < 4; ch++) { if ( p.cells[(r * pSize + (c + 1)) * 4 + ch] !== q.cells[(r * pSize + c) * 4 + ch] ) { canSitRight = false; break; } } if (!canSitRight) break; } if (!canSitRight) break; } } else { canSitRight = true; } if (canSitRight) { adjacency[p.id].right.push(q.index); ruleCount++; adjacency[q.id].left.push(p.index); ruleCount++; } let canSitDown = true; if (pSize > 1) { for (let c_col = 0; c_col < pSize; c_col++) { // Use c_col to avoid conflict with outer c for (let r_row = 0; r_row < pSize - 1; r_row++) { // Use r_row for (let ch = 0; ch < 4; ch++) { if ( p.cells[((r_row + 1) * pSize + c_col) * 4 + ch] !== q.cells[(r_row * pSize + c_col) * 4 + ch] ) { canSitDown = false; break; } } if (!canSitDown) break; } if (!canSitDown) break; } } else { canSitDown = true; } if (canSitDown) { adjacency[p.id].down.push(q.index); ruleCount++; adjacency[q.id].up.push(p.index); ruleCount++; } } } stats.adjacency.totalRules = ruleCount; let adjMem = estimateObjectOverhead(adjacency); if (adjacency) { for (const patId in adjacency) { adjMem += estimateStringBytes(patId); // For the key string adjMem += estimateObjectOverhead(adjacency[patId]); // For the {up:[], ...} object ['up', 'down', 'left', 'right'].forEach((dir) => { adjMem += estimateStringBytes(dir); // For "up", "down", etc. keys adjMem += adjacency[patId][dir].length * (patterns.length > 65535 ? 4 : 2) + 16; // Array of indices + array overhead }); } } stats.memory.adjacencyBytes = adjMem; } function addDirtyTile(c, r) { dirtyTilesForFrame.add(`${c},${r}`); } function processAndDrawDirtyTiles() { if (dirtyTilesForFrame.size === 0) return; dirtyTilesForFrame.forEach((key) => { const [c, r] = key.split(',').map(Number); if (grid && grid[r] && grid[r][c]) { drawTile(c, r); } }); dirtyTilesForFrame.clear(); } function initializeGrid() { outputCanvas.width = OUTPUT_SIZE; outputCanvas.height = OUTPUT_SIZE; pCols = OUTPUT_SIZE - (currentPatternSize - 1); pRows = OUTPUT_SIZE - (currentPatternSize - 1); if (pCols <= 0 || pRows <= 0) { statusEl.textContent = `Error: Output size (${OUTPUT_SIZE}x${OUTPUT_SIZE}) is too small for pattern size ${currentPatternSize}x${currentPatternSize}. Resulting pattern grid would be ${pCols}x${pRows} (must be >0).`; phase = 'error'; stats.wfcRun.status = `Error: Output size too small for patterns`; return; } stats.grid.pCols = pCols; stats.grid.pRows = pRows; stats.grid.initialCellOptions = patterns.length; const allPatternIndices = Array.from( { length: patterns.length }, (_, i) => i ); grid = Array.from({ length: pRows }, () => Array.from({ length: pCols }, () => [...allPatternIndices]) ); queue = []; queuedCoordinatesSet.clear(); maxTries = pCols * pRows * 30 * currentPatternSize; currentAttemptFailed = false; backtrackStack = []; let oneGridBytes = 16; // Outer array if (grid && grid.length > 0 && grid[0] && grid[0].length > 0) { oneGridBytes += grid.length * 16; // Each row array const cell = grid[0][0]; // Example cell (array of indices) const bytesPerIndex = patterns.length > 65535 ? 4 : patterns.length > 255 ? 2 : 1; // 1 byte if <256 patterns, 2 if <65536, else 4 const cellBytes = 16 + cell.length * bytesPerIndex; // Array of indices in cell + its overhead oneGridBytes += grid.length * grid[0].length * cellBytes; } stats.memory.estimatedGridSnapshotBytes = oneGridBytes; outputCtx.clearRect(0, 0, outputCanvas.width, outputCanvas.height); for (let r_idx = 0; r_idx < pRows; r_idx++) { for (let c_idx = 0; c_idx < pCols; c_idx++) { addDirtyTile(c_idx, r_idx); } } } function pickCellWithLowestEntropy() { if (!grid) return null; let bestEntropy = Infinity; let bestCells = []; for (let r = 0; r < pRows; r++) { for (let c = 0; c < pCols; c++) { if (!grid[r] || !grid[r][c]) continue; const numOptions = grid[r][c].length; if (numOptions > 1) { const currentEntropy = numOptions + Math.random() * 0.1; if (currentEntropy < bestEntropy) { bestEntropy = currentEntropy; bestCells = [[c, r]]; } else if (numOptions === Math.floor(bestEntropy)) { bestCells.push([c, r]); } } } } return bestCells.length > 0 ? bestCells[Math.floor(Math.random() * bestCells.length)] : null; } function collapseCell(c, r) { const initialOptionsForCellIndices = grid[r][c]; if (initialOptionsForCellIndices.length === 0) { currentAttemptFailed = true; stats.wfcRun.totalContradictions++; return; } if (backtrackStack.length >= MAX_BACKTRACK_DEPTH) { statusEl.textContent = `Max backtrack depth (${MAX_BACKTRACK_DEPTH}) reached at cell (${c},${r}). Forcing backtrack. Stack: ${backtrackStack.length}`; currentAttemptFailed = true; stats.wfcRun.forcedBacktracksByDepth++; stats.wfcRun.totalContradictions++; return; } let optionsToSystematicallyTry = [...initialOptionsForCellIndices]; const useRandomSelection = randomSelectionCheckbox.checked; if (useRandomSelection && optionsToSystematicallyTry.length > 1) { for (let i = optionsToSystematicallyTry.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [optionsToSystematicallyTry[i], optionsToSystematicallyTry[j]] = [ optionsToSystematicallyTry[j], optionsToSystematicallyTry[i], ]; } } let chosenPatternIndex; let chosenIndexInSystematicList = 0; if (!useRandomSelection && optionsToSystematicallyTry.length > 1) { let totalWeight = 0; const weights = optionsToSystematicallyTry.map((idx) => { const pat = patternByIndex[idx]; const w = pat && pat.frequency > 0 ? pat.frequency : 1; totalWeight += w; return w; }); if (totalWeight === 0) { chosenIndexInSystematicList = 0; } else { let rnd = Math.random() * totalWeight; for (let i = 0; i < optionsToSystematicallyTry.length; i++) { rnd -= weights[i]; if (rnd <= 0) { chosenIndexInSystematicList = i; break; } } } if (typeof chosenIndexInSystematicList === 'undefined') { chosenIndexInSystematicList = optionsToSystematicallyTry.length - 1; } const actualChosenItemIndex = optionsToSystematicallyTry.splice( chosenIndexInSystematicList, 1 )[0]; optionsToSystematicallyTry.unshift(actualChosenItemIndex); chosenIndexInSystematicList = 0; } chosenPatternIndex = optionsToSystematicallyTry[chosenIndexInSystematicList]; if (typeof chosenPatternIndex === 'undefined') { currentAttemptFailed = true; stats.wfcRun.totalContradictions++; return; } const gridSnapshot = deepCopyGrid(grid); const queueSnapshot = deepCopyQueue(queue); const queuedCoordinatesSetSnapshot = new Set(queuedCoordinatesSet); backtrackStack.push({ gridBeforeChoice: gridSnapshot, queueBeforeChoice: queueSnapshot, queuedCoordinatesSetBeforeChoice: queuedCoordinatesSetSnapshot, cellCoords: [c, r], optionsAtCell: optionsToSystematicallyTry, lastTriedOptionIndexInList: chosenIndexInSystematicList, }); stats.wfcRun.maxBacktrackStackDepthReached = Math.max( stats.wfcRun.maxBacktrackStackDepthReached, backtrackStack.length ); stats.memory.backtrackStackPeakBytes = Math.max( stats.memory.backtrackStackPeakBytes, backtrackStack.length * stats.memory.estimatedGridSnapshotBytes ); grid[r][c] = [chosenPatternIndex]; stats.wfcRun.cellsCollapsed++; addDirtyTile(c, r); const coordKey = `${c},${r}`; if (!queuedCoordinatesSet.has(coordKey)) { queue.push([c, r]); queuedCoordinatesSet.add(coordKey); } } function getPatternNeighbors(c, r) { return [ [c - 1, r, 'left'], [c + 1, r, 'right'], [c, r - 1, 'up'], [c, r + 1, 'down'], ].filter(([nc, nr]) => nc >= 0 && nr >= 0 && nc < pCols && nr < pRows); } function propagateStep() { stats.wfcRun.propagationSteps++; if (queue.length === 0) return { success: true }; const [c, r] = queue.shift(); queuedCoordinatesSet.delete(`${c},${r}`); if (!grid || !grid[r] || !grid[r][c]) { currentAttemptFailed = true; stats.wfcRun.totalContradictions++; return { success: false }; } const currentPatternIndicesInCell = grid[r][c]; if (currentPatternIndicesInCell.length === 0) { currentAttemptFailed = true; stats.wfcRun.totalContradictions++; return { success: false }; } for (const [nc, nr, dirToNeighbor] of getPatternNeighbors(c, r)) { if (!grid[nr] || !grid[nr][nc]) { currentAttemptFailed = true; stats.wfcRun.totalContradictions++; return { success: false }; } const originalNeighborOptionIndices = grid[nr][nc]; if (originalNeighborOptionIndices.length === 0) { currentAttemptFailed = true; stats.wfcRun.totalContradictions++; return { success: false }; } let cumulativeAllowedNeighborPatternIndices = new Set(); currentPatternIndicesInCell.forEach((p_idx) => { const p_id_str = patternByIndex[p_idx].id; if (!adjacency[p_id_str]) return; let compatiblePatternIndicesForNeighbor; if (dirToNeighbor === 'right') compatiblePatternIndicesForNeighbor = adjacency[p_id_str].right; else if (dirToNeighbor === 'left') compatiblePatternIndicesForNeighbor = adjacency[p_id_str].left; else if (dirToNeighbor === 'up') compatiblePatternIndicesForNeighbor = adjacency[p_id_str].up; else if (dirToNeighbor === 'down') compatiblePatternIndicesForNeighbor = adjacency[p_id_str].down; else return; compatiblePatternIndicesForNeighbor.forEach((q_idx) => cumulativeAllowedNeighborPatternIndices.add(q_idx) ); }); const newNeighborOptionIndices = originalNeighborOptionIndices.filter( (idx) => cumulativeAllowedNeighborPatternIndices.has(idx) ); if (newNeighborOptionIndices.length === 0) { currentAttemptFailed = true; stats.wfcRun.totalContradictions++; return { success: false }; } if ( newNeighborOptionIndices.length < originalNeighborOptionIndices.length ) { grid[nr][nc] = newNeighborOptionIndices; addDirtyTile(nc, nr); const neighborCoordKey = `${nc},${nr}`; if (!queuedCoordinatesSet.has(neighborCoordKey)) { queue.push([nc, nr]); queuedCoordinatesSet.add(neighborCoordKey); } } } return { success: true }; } function drawCollapsedPatternForCell(c, r) { const patIndex = grid[r][c][0]; const pat = patternByIndex[patIndex]; const pSize = currentPatternSize; if (!pat || !pat.cells) { outputCtx.fillStyle = 'rgba(255, 0, 255, 0.7)'; outputCtx.fillRect(c, r, pSize, pSize); return; } try { const imageData = outputCtx.createImageData(pSize, pSize); imageData.data.set(pat.cells); outputCtx.putImageData(imageData, c, r); } catch (e) { console.error( 'Error drawing collapsed cell:', e, 'Pattern Index:', patIndex, 'Coords:', c, r ); outputCtx.fillStyle = 'rgba(255, 165, 0, 0.7)'; outputCtx.fillRect(c, r, pSize, pSize); } } function drawAveragePatternForCell(c, r) { const possiblePatternIndices = grid[r][c]; if (!possiblePatternIndices || possiblePatternIndices.length === 0) return; const pSize = currentPatternSize; const tileImageData = outputCtx.createImageData(pSize, pSize); const tileData = tileImageData.data; for (let dy = 0; dy < pSize; dy++) { for (let dx = 0; dx < pSize; dx++) { let sumR = 0, sumG = 0, sumB = 0, sumA = 0; let count = 0; for (const patIndex of possiblePatternIndices) { const pattern = patternByIndex[patIndex]; if (pattern && pattern.cells) { const pixelStartIndex = (dy * pSize + dx) * 4; if (pixelStartIndex + 3 < pattern.cells.length) { sumR += pattern.cells[pixelStartIndex]; sumG += pattern.cells[pixelStartIndex + 1]; sumB += pattern.cells[pixelStartIndex + 2]; sumA += pattern.cells[pixelStartIndex + 3]; count++; } } } const dataIdx = (dy * pSize + dx) * 4; if (count > 0) { tileData[dataIdx] = Math.round(sumR / count); tileData[dataIdx + 1] = Math.round(sumG / count); tileData[dataIdx + 2] = Math.round(sumB / count); tileData[dataIdx + 3] = Math.round(sumA / count); } else { tileData[dataIdx] = 60; tileData[dataIdx + 1] = 60; tileData[dataIdx + 2] = 60; tileData[dataIdx + 3] = 128; } } } outputCtx.putImageData(tileImageData, c, r); } function drawTile(c, r) { if (!grid || !grid[r] || !grid[r][c]) return; const numOptions = grid[r][c].length; if (numOptions === 1) { drawCollapsedPatternForCell(c, r); } else if (numOptions > 1) { drawAveragePatternForCell(c, r); } else { outputCtx.fillStyle = 'rgba(255, 0, 0, 0.7)'; outputCtx.fillRect(c, r, currentPatternSize, currentPatternSize); } } function allCollapsed() { if (!grid) return false; for (let r_idx = 0; r_idx < pRows; r_idx++) { for (let c_idx = 0; c_idx < pCols; c_idx++) { if ( !grid[r_idx] || !grid[r_idx][c_idx] || grid[r_idx][c_idx].length > 1 ) { return false; } } } return true; } let lastStatsRenderTime = 0; const STATS_RENDER_INTERVAL = 250; function runStep() { if ( phase === 'error' || phase === 'loading' || phase === 'idle' || phase === 'loading_image_error' ) { processAndDrawDirtyTiles(); if ( phase === 'error' || phase === 'loading_image_error' || phase === 'done' ) { if (stats.wfcRun && stats.wfcRun.startTime > 0) { // Ensure startTime was set stats.wfcRun.endTime = performance.now(); stats.wfcRun.durationMs = stats.wfcRun.endTime - stats.wfcRun.startTime; } else if (stats.wfcRun) { stats.wfcRun.durationMs = 0; // Or some indicator it didn't run } } renderStats(); return; } if (phase === 'collapse') { if (currentAttemptFailed) { currentAttemptFailed = false; let backtrackedSuccessfully = false; stats.wfcRun.totalBacktracks++; while (backtrackStack.length > 0) { const lastDecision = backtrackStack.pop(); const { gridBeforeChoice, queueBeforeChoice, queuedCoordinatesSetBeforeChoice, cellCoords, optionsAtCell, lastTriedOptionIndexInList, } = lastDecision; const [cc, cr] = cellCoords; let nextOptionIndexToTryInList = lastTriedOptionIndexInList + 1; if (nextOptionIndexToTryInList < optionsAtCell.length) { grid = deepCopyGrid(gridBeforeChoice); queue = deepCopyQueue(queueBeforeChoice); queuedCoordinatesSet = new Set(queuedCoordinatesSetBeforeChoice); const newChosenPatternIndex = optionsAtCell[nextOptionIndexToTryInList]; grid[cr][cc] = [newChosenPatternIndex]; addDirtyTile(cc, cr); const coordKeyBacktrack = `${cc},${cr}`; if (!queuedCoordinatesSet.has(coordKeyBacktrack)) { queue.push([cc, cr]); queuedCoordinatesSet.add(coordKeyBacktrack); } if (backtrackStack.length < MAX_BACKTRACK_DEPTH) { backtrackStack.push({ gridBeforeChoice: gridBeforeChoice, queueBeforeChoice: queueBeforeChoice, queuedCoordinatesSetBeforeChoice: queuedCoordinatesSetBeforeChoice, cellCoords: cellCoords, optionsAtCell: optionsAtCell, lastTriedOptionIndexInList: nextOptionIndexToTryInList, }); stats.wfcRun.maxBacktrackStackDepthReached = Math.max( stats.wfcRun.maxBacktrackStackDepthReached, backtrackStack.length ); stats.memory.backtrackStackPeakBytes = Math.max( stats.memory.backtrackStackPeakBytes, backtrackStack.length * stats.memory.estimatedGridSnapshotBytes ); } else { console.warn( 'Backtrack: At MAX_BACKTRACK_DEPTH after pop and finding new option. This new choice cannot be further backtracked if it fails immediately due to depth limit on next step.' ); } statusEl.textContent = `Backtracked. Retrying option ${ nextOptionIndexToTryInList + 1 }/${optionsAtCell.length} at (${cc},${cr}). Stack: ${ backtrackStack.length }`; phase = 'propagate'; backtrackedSuccessfully = true; outputCtx.clearRect(0, 0, outputCanvas.width, outputCanvas.height); for (let r_idx = 0; r_idx < pRows; r_idx++) { for (let c_idx = 0; c_idx < pCols; c_idx++) { if (grid[r_idx] && grid[r_idx][c_idx]) { addDirtyTile(c_idx, r_idx); } } } break; } } if (!backtrackedSuccessfully) { statusEl.textContent = 'Backtrack stack exhausted → WFC failed. Restarting WFC.'; stats.wfcRun.status = 'Failed (Backtrack stack exhausted), Restarting...'; renderStats(); if (patterns && patterns.length > 0) { initializeGrid(); // This will reset some grid stats stats.wfcRun.startTime = performance.now(); // Reset for the new attempt stats.wfcRun.endTime = 0; stats.wfcRun.durationMs = 0; stats.wfcRun.maxBacktrackStackDepthReached = 0; stats.wfcRun.totalBacktracks = 0; stats.wfcRun.totalContradictions = 0; stats.wfcRun.forcedBacktracksByDepth = 0; stats.wfcRun.cellsCollapsed = 0; stats.wfcRun.propagationSteps = 0; stats.memory.backtrackStackPeakBytes = 0; // Reset peak for this new run if (phase === 'error') { processAndDrawDirtyTiles(); renderStats(); return; } phase = 'collapse'; stats.wfcRun.status = 'Running WFC (Restarted after fail)'; } else { phase = 'error'; stats.wfcRun.status = 'Error: Cannot restart, no patterns'; statusEl.textContent = 'Cannot restart: No patterns loaded. Load an image first.'; } processAndDrawDirtyTiles(); renderStats(); if (phase !== 'collapse') animationFrameId = null; else animationFrameId = requestAnimationFrame(runStep); return; } } if (allCollapsed()) { phase = 'done'; stats.wfcRun.status = 'WFC Complete!'; stats.wfcRun.endTime = performance.now(); stats.wfcRun.durationMs = stats.wfcRun.endTime - stats.wfcRun.startTime; statusEl.textContent = 'WFC complete!'; processAndDrawDirtyTiles(); renderStats(); animationFrameId = null; return; } if (maxTries-- <= 0 && phase !== 'propagate') { statusEl.textContent = 'Max tries reached → Restarting WFC (timeout).'; stats.wfcRun.status = 'Failed (Timeout), Restarting...'; renderStats(); if (patterns && patterns.length > 0) { initializeGrid(); stats.wfcRun.startTime = performance.now(); stats.wfcRun.endTime = 0; stats.wfcRun.durationMs = 0; stats.wfcRun.maxBacktrackStackDepthReached = 0; stats.wfcRun.totalBacktracks = 0; stats.wfcRun.totalContradictions = 0; stats.wfcRun.forcedBacktracksByDepth = 0; stats.wfcRun.cellsCollapsed = 0; stats.wfcRun.propagationSteps = 0; stats.memory.backtrackStackPeakBytes = 0; if (phase === 'error') { processAndDrawDirtyTiles(); renderStats(); return; } phase = 'collapse'; stats.wfcRun.status = 'Running WFC (Restarted after timeout)'; } else { phase = 'error'; stats.wfcRun.status = 'Error: Cannot restart, no patterns'; statusEl.textContent = 'Cannot restart: No patterns loaded.'; } processAndDrawDirtyTiles(); renderStats(); if (phase !== 'collapse') animationFrameId = null; else animationFrameId = requestAnimationFrame(runStep); return; } if (phase === 'collapse') { const cellToCollapse = pickCellWithLowestEntropy(); if (!cellToCollapse) { if (!allCollapsed()) { statusEl.textContent = 'Error: No cell to collapse, but not all collapsed. Contradiction.'; stats.wfcRun.status = 'Error: No cell to collapse (Contradiction)'; currentAttemptFailed = true; stats.wfcRun.totalContradictions++; } else { phase = 'done'; stats.wfcRun.status = 'WFC Complete (no cell, all done)'; stats.wfcRun.endTime = performance.now(); stats.wfcRun.durationMs = stats.wfcRun.endTime - stats.wfcRun.startTime; statusEl.textContent = 'WFC complete (no cell to collapse, all done).'; } } else { collapseCell(cellToCollapse[0], cellToCollapse[1]); if (!currentAttemptFailed) { phase = 'propagate'; } } } } else if (phase === 'propagate') { let processedInFrame = 0; const MAX_PROP_PER_FRAME = Math.max(30, Math.floor((pCols * pRows) / 15)); while ( processedInFrame++ < MAX_PROP_PER_FRAME && queue.length > 0 && !currentAttemptFailed ) { propagateStep(); } if (queue.length === 0 || currentAttemptFailed) { phase = 'collapse'; } } processAndDrawDirtyTiles(); const now = performance.now(); if (now - lastStatsRenderTime > STATS_RENDER_INTERVAL) { if ( phase !== 'done' && phase !== 'error' && phase !== 'loading_image_error' && stats.wfcRun.startTime > 0 ) { stats.wfcRun.durationMs = now - stats.wfcRun.startTime; } renderStats(); lastStatsRenderTime = now; } if ( phase !== 'done' && phase !== 'error' && phase !== 'loading_image_error' ) { animationFrameId = requestAnimationFrame(runStep); } else { // Done, error, or loading_image_error if ( stats.wfcRun && stats.wfcRun.startTime > 0 && stats.wfcRun.endTime === 0 ) { // Ensure endTime is set if run started stats.wfcRun.endTime = performance.now(); stats.wfcRun.durationMs = stats.wfcRun.endTime - stats.wfcRun.startTime; } else if (stats.wfcRun && stats.wfcRun.startTime === 0) { stats.wfcRun.durationMs = 0; // Didn't really run } renderStats(); // Final render for terminal states animationFrameId = null; } } resetWfcStateAndLoadNewImage(); })();