diff --git a/index.html b/index.html new file mode 100644 index 0000000..c99820e --- /dev/null +++ b/index.html @@ -0,0 +1,85 @@ + + + + + WFC with Web Workers + + + +
+ + + + + + + +

Loading...

+
+
+

Source Image (scaled)

+ +
+
+

Output

+ +
+
WFC Statistics will appear here.
+ + + diff --git a/old_main.js b/old_main.js new file mode 100644 index 0000000..b9e4850 --- /dev/null +++ b/old_main.js @@ -0,0 +1,1270 @@ +(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(); +})(); diff --git a/script.js b/script.js new file mode 100644 index 0000000..828caf6 --- /dev/null +++ b/script.js @@ -0,0 +1,970 @@ +// Main script (script.js) +(function () { + let currentImageUrl = + 'https://raw.githubusercontent.com/mxgmn/WaveFunctionCollapse/master/samples/Flowers.png'; + // To test with the uploaded image, you'd change the URL to 'input_file_0.png' + // For now, let's assume Flowers.png or a similar valid, varied image URL. + // currentImageUrl = 'input_file_0.png'; // If you want to test with the local file (requires serving) + + let currentPatternSize = 3; + let enableRotations = false; + let OUTPUT_SIZE = 64; + const SOURCE_VISUAL_SCALE = 4; + const MAX_BACKTRACK_DEPTH = 4096; + + 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 phase = 'idle'; + let dirtyTilesForFrame = new Set(); + let animationFrameId = null; + + let setupWorker; + let solverWorker; + + let stats = {}; + let patternsData = { + patterns: null, + patternByID: null, + patternIdToIndex: null, + adjacency: null, + patternByIndexForDrawing: null, + }; + + 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']; + 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; + } + + function estimateObjectOverhead(obj) { + if (!obj) return 0; + 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 * currentPatternSize}x${ + OUTPUT_SIZE * currentPatternSize + } (pixels)
`; + 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 (Solver): ~${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 (Solver): ~${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; + } + + imageUrlInput.value = currentImageUrl; + patternSizeSelect.value = currentPatternSize.toString(); + enableRotationsCheckbox.checked = enableRotations; + + const img = new Image(); + img.crossOrigin = 'Anonymous'; // Keep this for external images + + img.onload = () => { + if (phase === 'loading_image_error') return; + phase = 'processing_setup'; + stats.wfcRun.status = 'Processing Image...'; + renderStats(); + + const sampleWidth = img.width; + const sampleHeight = img.height; + + console.log( + `[Main Thread] img.onload: Image URL: ${img.src}, Loaded dimensions: ${sampleWidth}x${sampleHeight}` + ); + if (sampleWidth === 0 || sampleHeight === 0) { + console.error( + '[Main Thread] Image loaded with zero dimensions. Cannot proceed.' + ); + statusEl.textContent = `Error: Image loaded with zero dimensions.`; + phase = 'error'; + stats.wfcRun.status = 'Error: Image zero dimensions'; + renderStats(); + return; + } + + stats.sourceImage = { + loaded: true, + width: sampleWidth, + height: sampleHeight, + pixels: sampleWidth * sampleHeight, + }; + + sourceCanvas.width = sampleWidth; + sourceCanvas.height = sampleHeight; + console.log( + `[Main Thread] sourceCanvas dimensions set to: ${sourceCanvas.width}x${sourceCanvas.height}` + ); + + let beforeData = [0, 0, 0, 0]; // Default if getImageData fails + try { + // Clear canvas to a known state (e.g., transparent black) before drawing new image + // This helps ensure we are not reading stale data from a previous run if canvas is reused. + sourceCtx.clearRect(0, 0, sourceCanvas.width, sourceCanvas.height); + beforeData = sourceCtx.getImageData(0, 0, 1, 1).data; + console.log( + `[Main Thread] sourceCanvas pixel (0,0) AFTER clearRect & BEFORE drawImage: (${beforeData[0]},${beforeData[1]},${beforeData[2]},${beforeData[3]})` + ); + } catch (e) { + console.error( + '[Main Thread] Error getting pixel data BEFORE drawImage (after clearRect):', + e + ); + } + + sourceCtx.drawImage(img, 0, 0); + console.log('[Main Thread] sourceCtx.drawImage(img, 0, 0) called.'); + + 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'; + + setTimeout(() => { + if (phase !== 'processing_setup') { + console.log( + '[Main Thread] setTimeout: Phase changed during timeout, aborting worker start.' + ); + return; + } + + 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; + } + + let afterData = [0, 0, 0, 0]; + try { + afterData = sourceCtx.getImageData(0, 0, 1, 1).data; + console.log( + `[Main Thread] setTimeout: sourceCanvas pixel (0,0) AFTER drawImage: (${afterData[0]},${afterData[1]},${afterData[2]},${afterData[3]})` + ); + + if ( + afterData[0] === 191 && + afterData[1] === 232 && + afterData[2] === 242 + ) { + console.warn( + "[Main Thread] setTimeout WARNING: Pixel (0,0) is the uniform blue. 'drawImage' might not have rendered the image correctly or canvas is being cleared to this color." + ); + } else if ( + afterData[0] === 0 && + afterData[1] === 0 && + afterData[2] === 0 && + afterData[3] === 0 && + beforeData[3] !== 0 + ) { + console.warn( + "[Main Thread] setTimeout WARNING: Pixel (0,0) is transparent black. 'drawImage' might have cleared it or image is transparent." + ); + } else if ( + afterData[0] === beforeData[0] && + afterData[1] === beforeData[1] && + afterData[2] === beforeData[2] && + afterData[3] === beforeData[3] && + (afterData[0] !== 0 || + afterData[1] !== 0 || + afterData[2] !== 0 || + beforeData[3] !== 0) + ) { + // Only warn if the 'before' state wasn't already transparent black + if ( + !( + beforeData[0] === 0 && + beforeData[1] === 0 && + beforeData[2] === 0 && + beforeData[3] === 0 + ) + ) { + console.warn( + "[Main Thread] setTimeout WARNING: Pixel (0,0) did NOT change after drawImage. 'drawImage' likely had no effect or image is fully transparent." + ); + } + } + } catch (e) { + console.error( + '[Main Thread] setTimeout: Error getting pixel data AFTER drawImage:', + e + ); + } + + console.log( + '[Main Thread] setTimeout: About to getImageData for full srcData. sourceCanvas dimensions:', + sourceCanvas.width, + sourceCanvas.height + ); + const srcData = sourceCtx.getImageData( + 0, + 0, + sampleWidth, + sampleHeight + ).data; + + if (srcData && srcData.length > 0) { + let mainThreadPixelDataSample = []; + for (let i = 0; i < Math.min(100, srcData.length); i += 4) { + mainThreadPixelDataSample.push( + `(${srcData[i]},${srcData[i + 1]},${srcData[i + 2]},${ + srcData[i + 3] + })` + ); + } + console.log( + '[Main Thread] setTimeout: srcData first ~25 pixels after getImageData:', + mainThreadPixelDataSample.join(' ') + ); + + if (srcData[0] === 191 && srcData[1] === 232 && srcData[2] === 242) { + console.error( + '[Main Thread] setTimeout CRITICAL: Full getImageData is returning the uniform blue color. Problem with canvas painting before getImageData is definitive.' + ); + } else if ( + srcData.every((val, index) => + index % 4 === 3 ? val === 0 : val === 0 + ) + ) { + // Check if all data is transparent black + console.warn( + '[Main Thread] setTimeout WARNING: Full getImageData is returning all zeros (transparent black).' + ); + } + } else { + console.error( + '[Main Thread] setTimeout: srcData is null or empty after getImageData!' + ); + statusEl.textContent = `Error: Failed to get image pixel data.`; + phase = 'error'; + stats.wfcRun.status = 'Error: getImageData failed'; + renderStats(); + return; + } + + stats.wfcRun.status = 'Extracting patterns (Worker)...'; + statusEl.textContent = `Extracting ${currentPatternSize}x${currentPatternSize} patterns (Worker)...`; + renderStats(); + + setupWorker = new Worker('wfc_setup_worker.js'); + setupWorker.onmessage = handleSetupWorkerMessage; + setupWorker.onerror = (err) => { + console.error('Setup Worker Error:', err); + statusEl.textContent = `Setup Worker Error: ${err.message}`; + phase = 'error'; + stats.wfcRun.status = 'Error: Setup Worker Failed'; + renderStats(); + }; + + if ( + !srcData || + !srcData.buffer || + !(srcData.buffer instanceof ArrayBuffer) + ) { + console.error( + '[Main Thread] setTimeout: srcData.buffer is invalid for transfer!', + srcData + ); + statusEl.textContent = `Error: Image data buffer is invalid.`; + phase = 'error'; + stats.wfcRun.status = 'Error: Invalid buffer'; + renderStats(); + return; + } + + setupWorker.postMessage( + { + type: 'START_SETUP', + imageDataBuffer: srcData.buffer, + width: sampleWidth, + height: sampleHeight, + patternSize: currentPatternSize, + enableRotations: enableRotations, + }, + [srcData.buffer] + ); + }, 100); // Timeout value, can be adjusted + }; + + img.onerror = () => { + statusEl.textContent = + 'Error: Failed to load source image. Check URL and CORS policy.'; + phase = 'loading_image_error'; + stats.wfcRun.status = 'Error: Image load failed'; + stats.sourceImage.loaded = false; + renderStats(); + }; + + function resetWfcStateAndLoadNewImage() { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + if (setupWorker) setupWorker.terminate(); + if (solverWorker) solverWorker.terminate(); + + phase = 'loading'; + resetStats(); + stats.wfcRun.status = 'Loading image...'; + if (outputCanvas.width > 0 && outputCanvas.height > 0) { + outputCtx.clearRect(0, 0, outputCanvas.width, outputCanvas.height); + } + dirtyTilesForFrame.clear(); + + currentImageUrl = imageUrlInput.value.trim(); + currentPatternSize = parseInt(patternSizeSelect.value, 10); + enableRotations = enableRotationsCheckbox.checked; + + patternsData = { + patterns: null, + patternByID: null, + patternIdToIndex: null, + adjacency: null, + patternByIndexForDrawing: null, + }; + currentGridCellOptions = {}; + + if (!currentImageUrl) { + statusEl.textContent = 'Error: Image URL cannot be empty.'; + phase = 'error'; + stats.wfcRun.status = 'Error: No image URL'; + renderStats(); + return; + } + // If currentImageUrl is 'input_file_0.png', ensure it's served correctly by your local server. + // For testing, you might hardcode the GitHub URL if local serving of input_file_0.png is an issue. + // currentImageUrl = 'https://raw.githubusercontent.com/mxgmn/WaveFunctionCollapse/master/samples/Flowers.png'; // Fallback for testing + // imageUrlInput.value = currentImageUrl; // Update input field if hardcoded + + img.src = ''; + img.src = currentImageUrl; + statusEl.textContent = 'Loading image...'; + renderStats(); + } + restartButton.addEventListener('click', resetWfcStateAndLoadNewImage); + + function handleSetupWorkerMessage(e) { + const { type, payload, error } = e.data; + if (error || (payload && payload.error)) { + const errorMessage = error || payload.error; + console.error('Error from Setup Worker:', errorMessage); + statusEl.textContent = `Error from Setup Worker: ${errorMessage}`; + phase = 'error'; + stats.wfcRun.status = `Error: Setup failed (${errorMessage})`; + renderStats(); + return; + } + + if (type === 'SETUP_PROGRESS') { + statusEl.textContent = payload.statusMessage; + if (payload.statsUpdate) { + Object.assign( + stats.patternExtraction, + payload.statsUpdate.patternExtraction || {} + ); + Object.assign(stats.memory, payload.statsUpdate.memory || {}); + } + renderStats(); + } else if (type === 'SETUP_COMPLETE') { + const receivedPatternsData = payload.patternsData; + + patternsData.patterns = receivedPatternsData.patterns; + patternsData.patternIdToIndex = receivedPatternsData.patternIdToIndex; + patternsData.adjacency = receivedPatternsData.adjacency; + patternsData.patternByID = receivedPatternsData.patternByID; + + Object.assign(stats.patternExtraction, payload.stats.patternExtraction); + Object.assign(stats.adjacency, payload.stats.adjacency); + Object.assign(stats.memory, payload.stats.memory); + + if (!patternsData.patterns || patternsData.patterns.length === 0) { + statusEl.textContent = `Error: No patterns found by worker.`; + phase = 'error'; + stats.wfcRun.status = 'Error: No patterns from worker'; + renderStats(); + return; + } + + const sourceArrayForDrawing = + receivedPatternsData.patternByIndex || patternsData.patterns; + patternsData.patternByIndexForDrawing = sourceArrayForDrawing.map( + (p_orig, p_idx) => { + if (!(p_orig.cells instanceof Uint8Array)) { + console.error( + `CRITICAL in handleSetupWorkerMessage (map for drawing): Pattern at index ${p_idx}, p_orig.cells is NOT a Uint8Array. It is:`, + p_orig.cells + ); + if (p_orig.cells instanceof ArrayBuffer) { + return { + id: p_orig.id, + cells: new Uint8Array(p_orig.cells.slice(0)), + frequency: p_orig.frequency, + index: p_orig.index, + }; + } + throw new Error( + 'p_orig.cells is not a Uint8Array or ArrayBuffer in handleSetupWorkerMessage map' + ); + } + return { + id: p_orig.id, + cells: new Uint8Array(p_orig.cells.buffer.slice(0)), + frequency: p_orig.frequency, + index: p_orig.index, + }; + } + ); + + if ( + patternsData.patternByIndexForDrawing && + patternsData.patternByIndexForDrawing.length > 0 + ) { + const firstPatternForDrawing = patternsData.patternByIndexForDrawing[0]; + console.log( + `[Main Thread] After copy for drawing: Pattern 0 (${ + firstPatternForDrawing.id + }) - .cells is Uint8Array: ${ + firstPatternForDrawing.cells instanceof Uint8Array + }` + ); + console.log( + `[Main Thread] After copy for drawing: Pattern 0 .cells.length: ${firstPatternForDrawing.cells.length}` + ); + let pixelsToLog = []; + for ( + let i = 0; + i < + Math.min( + firstPatternForDrawing.cells.length, + currentPatternSize * currentPatternSize * 4 + ); + i += 4 + ) { + pixelsToLog.push( + `(${firstPatternForDrawing.cells[i]},${ + firstPatternForDrawing.cells[i + 1] + },${firstPatternForDrawing.cells[i + 2]},${ + firstPatternForDrawing.cells[i + 3] + })` + ); + } + console.log( + `[Main Thread] After copy for drawing: Pattern 0 pixels:`, + pixelsToLog.join(' ') + ); + } + + statusEl.textContent = 'Setup complete. Initializing WFC grid...'; + stats.wfcRun.status = `Initializing grid...`; + renderStats(); + initializeGridAndStartSolver(); + } + } + + function initializeGridAndStartSolver() { + const t2 = performance.now(); + const pCols = OUTPUT_SIZE; + const pRows = OUTPUT_SIZE; + + outputCanvas.width = pCols * currentPatternSize; + outputCanvas.height = pRows * currentPatternSize; + outputCtx.clearRect(0, 0, outputCanvas.width, outputCanvas.height); + + stats.grid.pCols = pCols; + stats.grid.pRows = pRows; + stats.grid.initialCellOptions = patternsData.patterns + ? patternsData.patterns.length + : 0; + stats.grid.timeMs = performance.now() - t2; + + if (patternsData.patternByIndexForDrawing) { + 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, + patternsData.patternByIndexForDrawing.map((_, i) => i) + ); + } + } + } + processAndDrawDirtyTiles(); + + statusEl.textContent = 'Starting WFC solver worker...'; + stats.wfcRun.status = 'Running WFC (Solver Worker)...'; + stats.wfcRun.startTime = performance.now(); + renderStats(); + + solverWorker = new Worker('wfc_solver_worker.js'); + solverWorker.onmessage = handleSolverWorkerMessage; + solverWorker.onerror = (err) => { + console.error('Solver Worker Error:', err); + statusEl.textContent = `Solver Worker Error: ${err.message}`; + phase = 'error'; + stats.wfcRun.status = 'Error: Solver Worker Failed'; + if (stats.wfcRun.startTime > 0 && stats.wfcRun.endTime === 0) { + stats.wfcRun.endTime = performance.now(); + stats.wfcRun.durationMs = stats.wfcRun.endTime - stats.wfcRun.startTime; + } + renderStats(); + }; + + const patternCellBuffersToTransfer = patternsData.patterns.map( + (p, index) => { + if (!p || !p.cells) { + console.error( + `Pattern at index ${index} or its .cells is null/undefined for transfer.`, + p + ); + throw new Error( + `Pattern at index ${index} or its .cells is null/undefined. Cannot get buffer.` + ); + } + if (!(p.cells instanceof Uint8Array)) { + console.error( + `Pattern at index ${index}: p.cells is NOT a Uint8Array for transfer. It is:`, + p.cells + ); + if (p.cells instanceof ArrayBuffer) { + console.warn( + `Pattern at index ${index}: p.cells is an ArrayBuffer. Using p.cells directly for transfer list.` + ); + return p.cells; + } + throw new Error( + `Pattern at index ${index}: p.cells is not a Uint8Array. Cannot safely get .buffer.` + ); + } + if (!(p.cells.buffer instanceof ArrayBuffer)) { + console.error( + `Pattern at index ${index}: p.cells is Uint8Array, but p.cells.buffer is NOT an ArrayBuffer. p.cells.buffer is:`, + p.cells.buffer + ); + throw new Error( + `Pattern at index ${index}: p.cells.buffer is not an ArrayBuffer.` + ); + } + return p.cells.buffer; + } + ); + + try { + solverWorker.postMessage( + { + type: 'START_SOLVE', + pCols: pCols, + pRows: pRows, + patterns: patternsData.patterns, + patternIdToIndex: patternsData.patternIdToIndex, + adjacency: patternsData.adjacency, + currentPatternSize: currentPatternSize, + outputPixelWidth: outputCanvas.width, + outputPixelHeight: outputCanvas.height, + maxBacktrackDepth: MAX_BACKTRACK_DEPTH, + useRandomSelection: randomSelectionCheckbox.checked, + }, + patternCellBuffersToTransfer + ); + } catch (cloneError) { + console.error('DataCloneError during postMessage to solver:', cloneError); + phase = 'error'; + stats.wfcRun.status = 'Error: DataCloneError posting to solver'; + renderStats(); + return; + } + phase = 'collapse_solve'; + } + + let lastStatsRenderTime = 0; + const STATS_RENDER_INTERVAL = 250; + + function handleSolverWorkerMessage(e) { + const { type, payload, error } = e.data; + + if (error) { + console.error('Error from Solver Worker:', error); + statusEl.textContent = `Error from Solver Worker: ${error}`; + phase = 'error'; + stats.wfcRun.status = `Error: Solver failed (${error})`; + if (stats.wfcRun.startTime > 0 && stats.wfcRun.endTime === 0) { + stats.wfcRun.endTime = performance.now(); + stats.wfcRun.durationMs = stats.wfcRun.endTime - stats.wfcRun.startTime; + } + renderStats(); + if (solverWorker) solverWorker.terminate(); + return; + } + + switch (type) { + case 'TILE_UPDATE': + payload.updates.forEach((update) => { + addDirtyTile(update.c, update.r, update.cellOptions); + }); + break; + case 'STATUS_UPDATE': + statusEl.textContent = payload.message; + stats.wfcRun.status = payload.wfcStatus || stats.wfcRun.status; + break; + case 'STATS_UPDATE': + Object.assign(stats.wfcRun, payload.wfcRun || {}); + Object.assign(stats.memory, payload.memory || {}); + break; + case 'WFC_COMPLETE': + phase = 'done'; + statusEl.textContent = 'WFC complete!'; + stats.wfcRun.status = 'WFC Complete!'; + if (payload.finalStats) + Object.assign(stats.wfcRun, payload.finalStats.wfcRun || {}); + if (stats.wfcRun.startTime > 0 && stats.wfcRun.endTime === 0) { + stats.wfcRun.endTime = performance.now(); + stats.wfcRun.durationMs = + stats.wfcRun.endTime - stats.wfcRun.startTime; + } + processAndDrawDirtyTiles(); + if (solverWorker) solverWorker.terminate(); + break; + case 'WFC_FAILED': + phase = 'error'; + statusEl.textContent = payload.message || 'WFC failed.'; + stats.wfcRun.status = payload.wfcStatus || 'WFC Failed'; + if (payload.finalStats) + Object.assign(stats.wfcRun, payload.finalStats.wfcRun || {}); + if (stats.wfcRun.startTime > 0 && stats.wfcRun.endTime === 0) { + stats.wfcRun.endTime = performance.now(); + stats.wfcRun.durationMs = + stats.wfcRun.endTime - stats.wfcRun.startTime; + } + processAndDrawDirtyTiles(); + if (solverWorker) solverWorker.terminate(); + break; + case 'REQUEST_RESTART_FROM_SOLVER': + statusEl.textContent = + payload.message || 'Solver requested restart. Re-initializing...'; + stats.wfcRun.status = payload.wfcStatus || 'Solver Restarting...'; + if (payload.currentRunStats) + Object.assign(stats.wfcRun, payload.currentRunStats.wfcRun || {}); + if (stats.wfcRun.startTime > 0) { + stats.wfcRun.endTime = performance.now(); + stats.wfcRun.durationMs = + stats.wfcRun.endTime - stats.wfcRun.startTime; + } + renderStats(); + stats.wfcRun.startTime = 0; + stats.wfcRun.endTime = 0; + stats.wfcRun.durationMs = 0; + stats.wfcRun.maxBacktrackStackDepthReached = 0; + stats.wfcRun.cellsCollapsed = 0; + stats.wfcRun.propagationSteps = 0; + stats.memory.backtrackStackPeakBytes = 0; + if (solverWorker) solverWorker.terminate(); + initializeGridAndStartSolver(); + return; + } + + processAndDrawDirtyTiles(); + + const now = performance.now(); + if ( + phase !== 'done' && + phase !== 'error' && + phase !== 'loading_image_error' && + stats.wfcRun.startTime > 0 + ) { + stats.wfcRun.durationMs = now - stats.wfcRun.startTime; + } + if ( + now - lastStatsRenderTime > STATS_RENDER_INTERVAL || + phase === 'done' || + phase === 'error' + ) { + renderStats(); + lastStatsRenderTime = now; + } + } + + let currentGridCellOptions = {}; + + function addDirtyTile(c, r, cellOptions) { + currentGridCellOptions[`${c},${r}`] = cellOptions; + dirtyTilesForFrame.add(`${c},${r}`); + if ( + !animationFrameId && + (phase === 'collapse_solve' || + phase === 'done' || + phase === 'processing_setup') + ) { + animationFrameId = requestAnimationFrame(() => { + processAndDrawDirtyTiles(); + animationFrameId = null; + }); + } + } + + function processAndDrawDirtyTiles() { + if (dirtyTilesForFrame.size === 0) return; + dirtyTilesForFrame.forEach((key) => { + const [c, r] = key.split(',').map(Number); + const cellOptions = currentGridCellOptions[key]; + if (cellOptions && patternsData.patternByIndexForDrawing) { + drawTile( + c, + r, + cellOptions, + patternsData.patternByIndexForDrawing, + currentPatternSize + ); + } + }); + dirtyTilesForFrame.clear(); + } + + function drawTile(c, r, cellOptions, patternArrayForDrawing, pSize) { + if (!cellOptions || !patternArrayForDrawing) return; + const numOptions = cellOptions.length; + const pixelX = c * pSize; + const pixelY = r * pSize; + + if (numOptions === 1) { + drawCollapsedPatternForCell( + pixelX, + pixelY, + cellOptions[0], + patternArrayForDrawing, + pSize + ); + } else if (numOptions > 1) { + drawAveragePatternForCell( + pixelX, + pixelY, + cellOptions, + patternArrayForDrawing, + pSize + ); + } else { + outputCtx.fillStyle = 'rgba(255, 0, 0, 0.7)'; + outputCtx.fillRect(pixelX, pixelY, pSize, pSize); + } + } + + function drawCollapsedPatternForCell( + pixelX, + pixelY, + patIndex, + patternArrayForDrawing, + pSize + ) { + const pat = patternArrayForDrawing[patIndex]; + if (!pat || !pat.cells) { + outputCtx.fillStyle = 'rgba(255, 0, 255, 0.7)'; + outputCtx.fillRect(pixelX, pixelY, pSize, pSize); + return; + } + try { + const imageData = outputCtx.createImageData(pSize, pSize); + imageData.data.set(pat.cells); + outputCtx.putImageData(imageData, pixelX, pixelY); + } catch (e) { + console.error( + 'Error drawing collapsed cell:', + e, + 'Pattern Index:', + patIndex, + 'Coords:', + pixelX, + pixelY, + 'Pattern data:', + pat + ); + outputCtx.fillStyle = 'rgba(255, 165, 0, 0.7)'; + outputCtx.fillRect(pixelX, pixelY, pSize, pSize); + } + } + + function drawAveragePatternForCell( + pixelX, + pixelY, + possiblePatternIndices, + patternArrayForDrawing, + pSize + ) { + if ( + !possiblePatternIndices || + possiblePatternIndices.length === 0 || + !patternArrayForDrawing + ) + return; + 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 = patternArrayForDrawing[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, pixelX, pixelY); + } + + resetWfcStateAndLoadNewImage(); +})(); diff --git a/style.css b/style.css new file mode 100644 index 0000000..792c262 --- /dev/null +++ b/style.css @@ -0,0 +1,65 @@ +body { + font-family: sans-serif; + display: flex; + flex-direction: column; + align-items: center; + margin: 20px; +} +#controls { + margin-bottom: 20px; + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + justify-content: center; +} +#controls input[type='text'], +#controls input[type='checkbox'] { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; +} +#controls label { + display: flex; + align-items: center; + gap: 5px; +} +#controls select, +#controls button { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f8f8f8; +} +#canvases { + display: flex; + gap: 20px; + align-items: flex-start; + justify-content: center; + flex-wrap: wrap; +} +#canvases > div { + display: flex; + flex-direction: column; + align-items: center; +} +#sourceCanvas { + border: 1px solid #ccc; + margin-top: 5px; + image-rendering: pixelated; /* Maintain pixelated look when scaled by CSS */ +} +#outputCanvas { + border: 1px solid #ccc; + margin-top: 5px; + width: 512px; /* Display size - 4x the drawing width */ + height: 512px; /* Display size - 4x the drawing height */ + image-rendering: pixelated; /* Keeps pixels sharp when scaled */ +} +#statsContainer { + width: 512px; +} +#status { + margin-top: 15px; + font-weight: bold; + min-height: 1.2em; /* Prevent layout shift */ +} diff --git a/wfc_setup_worker.js b/wfc_setup_worker.js new file mode 100644 index 0000000..4c4c197 --- /dev/null +++ b/wfc_setup_worker.js @@ -0,0 +1,398 @@ +// wfc_setup_worker.js +let sampleWidth, + sampleHeight, + srcDataView, + currentPatternSizeW, + enableRotationsW; +let patterns, patternByID, patternByIndex, patternIdToIndex; +let adjacency; + +// --- Helper functions (subset of original, or new for worker) --- +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 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; + } // Should not happen with defined angles + 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; +} + +// --- Stats helpers (subset for this worker) --- +function estimateStringBytes(str) { + return str ? str.length * 2 : 0; +} +function estimateObjectOverhead(obj) { + if (!obj) return 0; + return ( + Object.keys(obj).length * + (estimateStringBytes('key_placeholder_avg') + 8 + 4) + + 16 + ); +} + +let workerStats = { + patternExtraction: {}, + adjacency: {}, + memory: {}, +}; + +function extractPatterns() { + console.log( + '[Setup Worker] extractPatterns started. sampleWidth:', + sampleWidth, + 'sampleHeight:', + sampleHeight, + 'pSize:', + currentPatternSizeW + ); + // Log a small portion of srcDataView to check its general content + if (srcDataView && srcDataView.length > 0) { + console.log( + '[Setup Worker] srcDataView first 100 bytes:', + srcDataView.slice(0, 100) + ); + } else { + console.error( + '[Setup Worker] srcDataView is null, empty, or undefined at start of extractPatterns!' + ); + // If srcDataView is bad, we can't proceed meaningfully. + throw new Error('srcDataView is invalid in extractPatterns'); + } + + const t0 = performance.now(); + const countMap = new Map(); + const keyToFlatCells = new Map(); + const pSize = currentPatternSizeW; + 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; + + if (srcIdx + 3 >= srcDataView.length) { + // Boundary check + console.error( + `[Setup Worker] Out of bounds access attempt: srcIdx=${srcIdx}, y=${y},x=${x}, dy=${dy},dx=${dx}, imgPixelY=${imgPixelY},imgPixelX=${imgPixelX}, sampleWidth=${sampleWidth}, sampleHeight=${sampleHeight}, srcDataView.length=${srcDataView.length}` + ); + // Fill with a debug color or handle error + originalFlatBlock[k++] = 255; + originalFlatBlock[k++] = 0; + originalFlatBlock[k++] = 0; + originalFlatBlock[k++] = 255; // Red + continue; // Skip this pixel + } + + originalFlatBlock[k++] = srcDataView[srcIdx]; + originalFlatBlock[k++] = srcDataView[srcIdx + 1]; + originalFlatBlock[k++] = srcDataView[srcIdx + 2]; + originalFlatBlock[k++] = srcDataView[srcIdx + 3]; + } + } + + if (x === 0 && y === 0) { + // Log only the very first extracted block + console.log( + '[Setup Worker] First originalFlatBlock (x=0, y=0, pSize=' + + pSize + + '):' + ); + let pixelsToLog = []; + for (let i = 0; i < originalFlatBlock.length; i += 4) { + pixelsToLog.push( + `(${originalFlatBlock[i]},${originalFlatBlock[i + 1]},${ + originalFlatBlock[i + 2] + },${originalFlatBlock[i + 3]})` + ); + } + console.log(pixelsToLog.join(' ')); + } + + const rotationsToConsider = enableRotationsW ? [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); + } + } + } + } + workerStats.patternExtraction.rawPatternCount = + rawPatternCounter * (enableRotationsW ? 4 : 1); + + patterns = []; + patternByID = {}; + patternByIndex = []; // This will be an array of pattern objects + patternIdToIndex = {}; + let idx = 0; + for (const [key, freq] of countMap.entries()) { + const patID = 'p' + idx; + const flatCellsData = keyToFlatCells.get(key); // This is a Uint8Array + const patObj = { + id: patID, + cells: flatCellsData, + frequency: freq, + index: idx, + }; + patterns.push(patObj); + patternByID[patID] = patObj; + patternByIndex[idx] = patObj; // Store by index + patternIdToIndex[patID] = idx; + idx++; + } + workerStats.patternExtraction.timeMs = performance.now() - t0; + workerStats.patternExtraction.uniquePatternCount = patterns.length; + workerStats.patternExtraction.rotationsEnabled = enableRotationsW; + + workerStats.memory.patternCellsBytes = 0; + workerStats.memory.patternsArrayBytes = 0; + patterns.forEach((p) => { + workerStats.memory.patternCellsBytes += p.cells.byteLength; + workerStats.memory.patternsArrayBytes += + estimateStringBytes(p.id) + 4 + 4 + 8 + 16; + }); + workerStats.memory.patternsArrayBytes += 16; + workerStats.memory.patternByIdBytes = estimateObjectOverhead(patternByID); + Object.keys(patternByID).forEach((key) => { + workerStats.memory.patternByIdBytes += estimateStringBytes(key) + 8; + }); + + self.postMessage({ + type: 'SETUP_PROGRESS', + payload: { + statusMessage: `Found ${patterns.length} unique patterns. Building adjacency...`, + statsUpdate: { + patternExtraction: workerStats.patternExtraction, + memory: workerStats.memory, + }, + }, + }); +} + +function buildAdjacency() { + const t1 = performance.now(); + adjacency = {}; + if (!patterns) return; + const pSize = currentPatternSizeW; + let ruleCount = 0; + + for (const pat of patterns) { + // patterns is an array of pattern objects + 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++) { + for (let r_row = 0; r_row < pSize - 1; 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++; + } + } + } + workerStats.adjacency.totalRules = ruleCount; + workerStats.adjacency.timeMs = performance.now() - t1; + + let adjMem = estimateObjectOverhead(adjacency); + if (adjacency) { + for (const patId in adjacency) { + adjMem += estimateStringBytes(patId); + adjMem += estimateObjectOverhead(adjacency[patId]); + ['up', 'down', 'left', 'right'].forEach((dir) => { + adjMem += estimateStringBytes(dir); + adjMem += + adjacency[patId][dir].length * (patterns.length > 65535 ? 4 : 2) + 16; + }); + } + } + workerStats.memory.adjacencyBytes = adjMem; +} + +self.onmessage = function (e) { + const { type, imageDataBuffer, width, height, patternSize, enableRotations } = + e.data; + + if (type === 'START_SETUP') { + console.log( + '[Setup Worker] Received START_SETUP. Image data buffer length:', + imageDataBuffer ? imageDataBuffer.byteLength : 'N/A' + ); + sampleWidth = width; + sampleHeight = height; + srcDataView = new Uint8ClampedArray(imageDataBuffer); + currentPatternSizeW = patternSize; + enableRotationsW = enableRotations; + + try { + extractPatterns(); + if (!patterns || patterns.length === 0) { + self.postMessage({ + type: 'SETUP_COMPLETE', + error: 'No patterns extracted.', + }); // Send error within SETUP_COMPLETE for main thread to handle + return; + } + buildAdjacency(); + + // patterns[i].cells are Uint8Arrays. Their .buffer will be transferred. + const transferablePatternCellBuffers = patterns.map( + (p) => p.cells.buffer + ); + + self.postMessage( + { + type: 'SETUP_COMPLETE', + payload: { + patternsData: { + patterns: patterns, // Array of pattern objects, .cells are Uint8Array + patternByID: patternByID, + patternByIndex: patternByIndex, // Send this explicitly if main thread uses it by this name + patternIdToIndex: patternIdToIndex, + adjacency: adjacency, + }, + stats: workerStats, + }, + }, + transferablePatternCellBuffers + ); + } catch (err) { + console.error('[Setup Worker] Error during setup process:', err); + self.postMessage({ + type: 'SETUP_COMPLETE', + error: err.message + (err.stack ? '\n' + err.stack : ''), + }); + } + } +}; + +// Catch unhandled errors in the worker +self.onerror = function (message, source, lineno, colno, error) { + console.error( + '[Setup Worker] Unhandled error:', + message, + source, + lineno, + colno, + error + ); + // Try to inform the main thread, though if postMessage itself fails, this might not work. + try { + self.postMessage({ + type: 'SETUP_COMPLETE', + error: `Unhandled error in setup worker: ${message}`, + }); + } catch (e) { + console.error( + '[Setup Worker] Failed to postMessage about unhandled error.', + e + ); + } + return true; // Prevents default error handling if possible +}; diff --git a/wfc_solver_worker.js b/wfc_solver_worker.js new file mode 100644 index 0000000..49e6d26 --- /dev/null +++ b/wfc_solver_worker.js @@ -0,0 +1,641 @@ +// wfc_solver_worker.js +let grid, + pCols, + pRows, + patternsW, + patternIdToIndexW, + adjacencyW, + currentPatternSizeW_solver; +let queue, queuedCoordinatesSet, backtrackStack; +let currentAttemptFailed; +let maxTriesInWorker; // Renamed from maxTries to avoid confusion +let useRandomSelectionW; +let MAX_BACKTRACK_DEPTH_W; + +// --- Worker Stats (subset for WFC run) --- +let solverRunStats = {}; +function resetSolverRunStats() { + solverRunStats = { + maxBacktrackStackDepthReached: 0, + totalBacktracks: 0, + totalContradictions: 0, + forcedBacktracksByDepth: 0, + cellsCollapsed: 0, + propagationSteps: 0, + // startTime/endTime/durationMs will be managed by main thread based on messages + }; +} +let solverMemoryStats = { + estimatedGridSnapshotBytes: 0, // Will be calculated once grid is initialized + backtrackStackPeakBytes: 0, +}; + +// --- Helper functions for solver (deepCopy, getPatternNeighbors, etc.) --- +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)); +} +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 initializeGridInWorker() { + const allPatternIndices = Array.from( + { length: patternsW.length }, + (_, i) => i + ); + grid = Array.from({ length: pRows }, () => + Array.from({ length: pCols }, () => [...allPatternIndices]) + ); + queue = []; + queuedCoordinatesSet = new Set(); + maxTriesInWorker = pCols * pRows * 30 * currentPatternSizeW_solver; // Original logic for max tries + currentAttemptFailed = false; + backtrackStack = []; + resetSolverRunStats(); // Reset stats for this run + + // Estimate memory for one grid snapshot (for backtrack stack calculations) + 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]; + const bytesPerIndex = + patternsW.length > 65535 ? 4 : patternsW.length > 255 ? 2 : 1; + const cellBytes = 16 + cell.length * bytesPerIndex; + oneGridBytes += grid.length * grid[0].length * cellBytes; + } + solverMemoryStats.estimatedGridSnapshotBytes = oneGridBytes; + solverMemoryStats.backtrackStackPeakBytes = 0; // Reset for this run + + // Send initial grid state (all tiles are "dirty" with all options) + const initialUpdates = []; + for (let r = 0; r < pRows; r++) { + for (let c = 0; c < pCols; c++) { + initialUpdates.push({ c, r, cellOptions: [...grid[r][c]] }); + } + } + if (initialUpdates.length > 0) { + self.postMessage({ + type: 'TILE_UPDATE', + payload: { updates: initialUpdates }, + }); + } + self.postMessage({ + type: 'STATS_UPDATE', + payload: { memory: solverMemoryStats }, + }); +} + +function pickCellWithLowestEntropy() { + /* ... Same as original ... */ + 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; // Add jitter + if (currentEntropy < bestEntropy) { + bestEntropy = currentEntropy; + bestCells = [[c, r]]; + } else if (numOptions === Math.floor(bestEntropy)) { + // Check only integer part for equality + bestCells.push([c, r]); + } + } + } + } + return bestCells.length > 0 + ? bestCells[Math.floor(Math.random() * bestCells.length)] + : null; +} + +function collapseCell(c, r) { + /* ... Same as original, but use solverRunStats and post TILE_UPDATE ... */ + const initialOptionsForCellIndices = grid[r][c]; + if (initialOptionsForCellIndices.length === 0) { + currentAttemptFailed = true; + solverRunStats.totalContradictions++; + return; + } + + if (backtrackStack.length >= MAX_BACKTRACK_DEPTH_W) { + // Post status about max depth reached + self.postMessage({ + type: 'STATUS_UPDATE', + payload: { + message: `Max backtrack depth (${MAX_BACKTRACK_DEPTH_W}) reached. Forcing backtrack.`, + }, + }); + currentAttemptFailed = true; + solverRunStats.forcedBacktracksByDepth++; + solverRunStats.totalContradictions++; + return; + } + + let optionsToSystematicallyTry = [...initialOptionsForCellIndices]; + // Randomize if checkbox was checked + if (useRandomSelectionW && 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], + ]; + } + } else if (!useRandomSelectionW && optionsToSystematicallyTry.length > 1) { + // Weighted selection + let totalWeight = 0; + const weights = optionsToSystematicallyTry.map((idx) => { + const pat = patternsW[idx]; // patternsW is array of pattern objects + const w = pat && pat.frequency > 0 ? pat.frequency : 1; + totalWeight += w; + return w; + }); + if (totalWeight === 0) { + // Should not happen if frequencies are positive + // Fallback to random if all weights are zero + } else { + let rnd = Math.random() * totalWeight; + let chosenIndexInSystematicList; + 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; // Should not be needed + + // Move chosen to front + const actualChosenItem = optionsToSystematicallyTry.splice( + chosenIndexInSystematicList, + 1 + )[0]; + optionsToSystematicallyTry.unshift(actualChosenItem); + } + } + + let chosenPatternIndex = optionsToSystematicallyTry[0]; // Try the first one (either random or weighted first) + + if (typeof chosenPatternIndex === 'undefined') { + currentAttemptFailed = true; + solverRunStats.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: 0, + }); + solverRunStats.maxBacktrackStackDepthReached = Math.max( + solverRunStats.maxBacktrackStackDepthReached, + backtrackStack.length + ); + solverMemoryStats.backtrackStackPeakBytes = Math.max( + solverMemoryStats.backtrackStackPeakBytes, + backtrackStack.length * solverMemoryStats.estimatedGridSnapshotBytes + ); + self.postMessage({ + type: 'STATS_UPDATE', + payload: { + wfcRun: { + maxBacktrackStackDepthReached: + solverRunStats.maxBacktrackStackDepthReached, + }, + memory: { + backtrackStackPeakBytes: solverMemoryStats.backtrackStackPeakBytes, + }, + }, + }); + + grid[r][c] = [chosenPatternIndex]; + solverRunStats.cellsCollapsed++; + self.postMessage({ + type: 'TILE_UPDATE', + payload: { updates: [{ c, r, cellOptions: grid[r][c] }] }, + }); + + const coordKey = `${c},${r}`; + if (!queuedCoordinatesSet.has(coordKey)) { + queue.push([c, r]); + queuedCoordinatesSet.add(coordKey); + } +} + +function propagateStep() { + /* ... Same as original, but use solverRunStats and post TILE_UPDATE ... */ + solverRunStats.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; + solverRunStats.totalContradictions++; + return { success: false }; + } + const currentPatternIndicesInCell = grid[r][c]; + if (currentPatternIndicesInCell.length === 0) { + currentAttemptFailed = true; + solverRunStats.totalContradictions++; + return { success: false }; + } + + const tileUpdatesForPropagation = []; + + for (const [nc, nr, dirToNeighbor] of getPatternNeighbors(c, r)) { + if (!grid[nr] || !grid[nr][nc]) { + currentAttemptFailed = true; + solverRunStats.totalContradictions++; + return { success: false }; + } + const originalNeighborOptionIndices = grid[nr][nc]; + if (originalNeighborOptionIndices.length === 0) { + currentAttemptFailed = true; + solverRunStats.totalContradictions++; + return { success: false }; // Already a contradiction + } + + let cumulativeAllowedNeighborPatternIndices = new Set(); + currentPatternIndicesInCell.forEach((p_idx) => { + const p_id_str = patternsW[p_idx].id; // patternsW is array of pattern objects + if (!adjacencyW[p_id_str]) return; // Should not happen with valid adjacency + + let compatibleIndices; + if (dirToNeighbor === 'right') + compatibleIndices = adjacencyW[p_id_str].right; + else if (dirToNeighbor === 'left') + compatibleIndices = adjacencyW[p_id_str].left; + else if (dirToNeighbor === 'up') + compatibleIndices = adjacencyW[p_id_str].up; + else if (dirToNeighbor === 'down') + compatibleIndices = adjacencyW[p_id_str].down; + else return; + + compatibleIndices.forEach((q_idx) => + cumulativeAllowedNeighborPatternIndices.add(q_idx) + ); + }); + + const newNeighborOptionIndices = originalNeighborOptionIndices.filter( + (idx) => cumulativeAllowedNeighborPatternIndices.has(idx) + ); + + if (newNeighborOptionIndices.length === 0) { + currentAttemptFailed = true; + solverRunStats.totalContradictions++; + return { success: false }; + } + + if ( + newNeighborOptionIndices.length < originalNeighborOptionIndices.length + ) { + grid[nr][nc] = newNeighborOptionIndices; + tileUpdatesForPropagation.push({ + c: nc, + r: nr, + cellOptions: grid[nr][nc], + }); + + const neighborCoordKey = `${nc},${nr}`; + if (!queuedCoordinatesSet.has(neighborCoordKey)) { + queue.push([nc, nr]); + queuedCoordinatesSet.add(neighborCoordKey); + } + } + } + if (tileUpdatesForPropagation.length > 0) { + self.postMessage({ + type: 'TILE_UPDATE', + payload: { updates: tileUpdatesForPropagation }, + }); + } + return { success: true }; +} + +function allCollapsed() { + /* ... Same as original ... */ + 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 solverPhase = 'collapse'; // 'collapse' or 'propagate' +let runLoopTimeoutId = null; + +function runStepInWorker() { + if (runLoopTimeoutId) clearTimeout(runLoopTimeoutId); // Clear previous timeout if any + + if (solverPhase === 'stop') { + // A way to stop the loop if main thread terminates worker + return; + } + + if (currentAttemptFailed) { + currentAttemptFailed = false; + let backtrackedSuccessfully = false; + solverRunStats.totalBacktracks++; + self.postMessage({ + type: 'STATS_UPDATE', + payload: { wfcRun: { totalBacktracks: solverRunStats.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); // Restore state + queue = deepCopyQueue(queueBeforeChoice); + queuedCoordinatesSet = new Set(queuedCoordinatesSetBeforeChoice); + + const newChosenPatternIndex = optionsAtCell[nextOptionIndexToTryInList]; + grid[cr][cc] = [newChosenPatternIndex]; + // Post update for the cell being retried + self.postMessage({ + type: 'TILE_UPDATE', + payload: { updates: [{ c: cc, r: cr, cellOptions: grid[cr][cc] }] }, + }); + + const coordKeyBacktrack = `${cc},${cr}`; + if (!queuedCoordinatesSet.has(coordKeyBacktrack)) { + queue.push([cc, cr]); + queuedCoordinatesSet.add(coordKeyBacktrack); + } + + // Push new decision point for this choice + if (backtrackStack.length < MAX_BACKTRACK_DEPTH_W) { + // Check before pushing + backtrackStack.push({ + ...lastDecision, // Keep most of the old data + lastTriedOptionIndexInList: nextOptionIndexToTryInList, // Update the tried index + }); + solverRunStats.maxBacktrackStackDepthReached = Math.max( + solverRunStats.maxBacktrackStackDepthReached, + backtrackStack.length + ); + solverMemoryStats.backtrackStackPeakBytes = Math.max( + solverMemoryStats.backtrackStackPeakBytes, + backtrackStack.length * solverMemoryStats.estimatedGridSnapshotBytes + ); + self.postMessage({ + type: 'STATS_UPDATE', + payload: { + wfcRun: { + maxBacktrackStackDepthReached: + solverRunStats.maxBacktrackStackDepthReached, + }, + memory: { + backtrackStackPeakBytes: + solverMemoryStats.backtrackStackPeakBytes, + }, + }, + }); + } else { + // Cannot push new state if max depth would be exceeded by this push. + // This implies this new choice, if it leads to immediate contradiction, cannot be further backtracked from this exact point. + // This state is tricky; the original code had a check *before* collapse. + // For now, we just don't push if it would exceed. + } + + self.postMessage({ + type: 'STATUS_UPDATE', + payload: { + message: `Backtracked. Retrying option ${ + nextOptionIndexToTryInList + 1 + }/${optionsAtCell.length} at (${cc},${cr}). Stack: ${ + backtrackStack.length + }`, + }, + }); + solverPhase = 'propagate'; + backtrackedSuccessfully = true; + + // When backtracking, the entire grid might have changed, so signal main to redraw all from current worker grid state. + // This is simpler than tracking all individual changes during backtrack restoration. + const allTilesUpdate = []; + 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]) { + // Ensure cell exists + allTilesUpdate.push({ + c: c_idx, + r: r_idx, + cellOptions: [...grid[r_idx][c_idx]], + }); + } + } + } + if (allTilesUpdate.length > 0) { + self.postMessage({ + type: 'TILE_UPDATE', + payload: { updates: allTilesUpdate }, + }); + } + break; // Exit backtrack loop + } + } + + if (!backtrackedSuccessfully) { + self.postMessage({ + type: 'REQUEST_RESTART_FROM_SOLVER', + payload: { + message: + 'Backtrack stack exhausted -> WFC failed in worker. Requesting restart.', + wfcStatus: 'Failed (Backtrack Exhausted in Worker)', + currentRunStats: { wfcRun: solverRunStats }, // Send current stats before restart + }, + }); + solverPhase = 'stop'; // Stop this worker's loop + return; + } + } + + if (allCollapsed()) { + solverPhase = 'stop'; + self.postMessage({ + type: 'WFC_COMPLETE', + payload: { finalStats: { wfcRun: solverRunStats } }, + }); + return; + } + + if (maxTriesInWorker-- <= 0 && solverPhase !== 'propagate') { + self.postMessage({ + type: 'REQUEST_RESTART_FROM_SOLVER', + payload: { + message: 'Max tries reached in worker -> Requesting restart.', + wfcStatus: 'Failed (Timeout in Worker)', + currentRunStats: { wfcRun: solverRunStats }, + }, + }); + solverPhase = 'stop'; + return; + } + + if (solverPhase === 'collapse') { + const cellToCollapse = pickCellWithLowestEntropy(); + if (!cellToCollapse) { + if (!allCollapsed()) { + // Should be a contradiction if no cell to collapse but not all done + currentAttemptFailed = true; // Trigger backtrack + solverRunStats.totalContradictions++; + self.postMessage({ + type: 'STATUS_UPDATE', + payload: { + message: + 'Error: No cell to collapse, but not all collapsed. Contradiction.', + }, + }); + } else { + // Should have been caught by allCollapsed() earlier + solverPhase = 'stop'; + self.postMessage({ + type: 'WFC_COMPLETE', + payload: { finalStats: { wfcRun: solverRunStats } }, + }); + return; + } + } else { + collapseCell(cellToCollapse[0], cellToCollapse[1]); + if (!currentAttemptFailed) { + solverPhase = 'propagate'; + } + } + } else if (solverPhase === 'propagate') { + const MAX_PROP_PER_BATCH = Math.max(30, Math.floor((pCols * pRows) / 20)); // Batch propagations + let processedInBatch = 0; + let propResult = { success: true }; + + while ( + processedInBatch++ < MAX_PROP_PER_BATCH && + queue.length > 0 && + !currentAttemptFailed + ) { + propResult = propagateStep(); + if (!propResult.success) break; // currentAttemptFailed would be true + } + + if (queue.length === 0 || currentAttemptFailed || !propResult.success) { + solverPhase = 'collapse'; // Go back to collapse if queue empty or contradiction + } + } + + // Post periodic stat updates + self.postMessage({ + type: 'STATS_UPDATE', + payload: { wfcRun: solverRunStats }, + }); + + if (solverPhase !== 'stop') { + runLoopTimeoutId = setTimeout(runStepInWorker, 0); // Continue the loop non-blockingly + } +} + +self.onmessage = function (e) { + const { + type, + pCols: pc, + pRows: pr, + patterns: p, + patternIdToIndex: pIdToIdx, + adjacency: adj, + currentPatternSize: cPS, + maxBacktrackDepth, + useRandomSelection, + } = e.data; + + if (type === 'START_SOLVE') { + pCols = pc; + pRows = pr; + // patternsW are received with ArrayBuffers for cells. These are now owned by this worker. + // We need to reconstruct them into Uint8Arrays if they are not already. + // Assuming main thread sent them as objects with .cells being ArrayBuffer. + patternsW = p.map((pat) => ({ ...pat, cells: new Uint8Array(pat.cells) })); + patternIdToIndexW = pIdToIdx; + adjacencyW = adj; + currentPatternSizeW_solver = cPS; + MAX_BACKTRACK_DEPTH_W = maxBacktrackDepth; + useRandomSelectionW = useRandomSelection; + + solverPhase = 'collapse'; // Initial phase + + try { + initializeGridInWorker(); + self.postMessage({ + type: 'STATUS_UPDATE', + payload: { + message: `Solver worker started. Grid: ${pCols}x${pRows}. Patterns: ${patternsW.length}`, + }, + }); + runStepInWorker(); // Start the loop + } catch (err) { + console.error('Error in solver worker START_SOLVE:', err); + self.postMessage({ + type: null, + error: err.message + (err.stack ? '\n' + err.stack : ''), + }); + solverPhase = 'stop'; + } + } +}; + +// Catch unhandled errors in the worker +self.onerror = function (message, source, lineno, colno, error) { + console.error( + 'Unhandled error in Solver Worker:', + message, + source, + lineno, + colno, + error + ); + self.postMessage({ + type: null, + error: `Unhandled: ${message} ${error ? error.stack : ''}`, + }); + solverPhase = 'stop'; // Stop processing on unhandled error + return true; // Prevents default error handling +};