This commit is contained in:
Morten Andersson 2025-06-05 21:36:55 +02:00
parent 76c4c83a2d
commit 5ebcaa3714
6 changed files with 3429 additions and 0 deletions

85
index.html Normal file
View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>WFC with Web Workers</title>
<style>
body {
font-family: sans-serif;
display: flex;
flex-wrap: wrap;
gap: 20px;
}
canvas {
border: 1px solid black;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
#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 */
}
#controls {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 250px;
}
#statsContainer {
font-size: 0.9em;
white-space: pre-wrap;
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
min-width: 300px;
max-height: 80vh;
overflow-y: auto;
}
</style>
</head>
<body>
<div id="controls">
<label for="imageUrlInput">Image URL:</label>
<input
type="text"
id="imageUrlInput"
value="https://raw.githubusercontent.com/mxgmn/WaveFunctionCollapse/master/samples/Flowers.png"
style="width: 100%"
/>
<label for="patternSizeSelect">Pattern Size (N):</label>
<select id="patternSizeSelect">
<option value="2">2</option>
<option value="3" selected>3</option>
<option value="4">4</option>
</select>
<label
><input type="checkbox" id="randomSelectionCheckbox" /> Random Selection
for Collapse</label
>
<label
><input type="checkbox" id="enableRotationsCheckbox" /> Enable
Rotations</label
>
<button id="restartButton">Load Image & Run WFC</button>
<p id="status">Loading...</p>
</div>
<div>
<h3>Source Image (scaled)</h3>
<canvas id="sourceCanvas"></canvas>
</div>
<div>
<h3>Output</h3>
<canvas id="outputCanvas"></canvas>
</div>
<div id="statsContainer">WFC Statistics will appear here.</div>
<script src="script.js"></script>
</body>
</html>

1270
old_main.js Normal file

File diff suppressed because it is too large Load Diff

970
script.js Normal file
View File

@ -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 = `<strong>WFC Statistics:</strong><br />`;
html += `Status: ${stats.wfcRun.status}<br />`;
if (stats.wfcRun.durationMs > 0) {
html += `Total Time: ${(stats.wfcRun.durationMs / 1000).toFixed(
2
)}s<br />`;
}
html += `<br />`;
if (stats.sourceImage.loaded) {
html += `<strong>Source Image:</strong><br />`;
html += ` Dimensions: ${stats.sourceImage.width}x${stats.sourceImage.height}<br />`;
html += ` Pattern Size: ${currentPatternSize}x${currentPatternSize}<br />`;
html += ` Rotations Enabled: ${stats.patternExtraction.rotationsEnabled}<br />`;
html += `<br />`;
html += `<strong>Pattern Extraction (${(
stats.patternExtraction.timeMs / 1000
).toFixed(2)}s):</strong><br />`;
html += ` Raw Overlapping Patterns: ${stats.patternExtraction.rawPatternCount}<br />`;
html += ` Unique Patterns: ${stats.patternExtraction.uniquePatternCount}<br />`;
html += ` Memory (Pattern Objects): ~${formatBytes(
stats.memory.patternsArrayBytes
)}<br />`;
html += ` Memory (Pattern Pixel Data): ~${formatBytes(
stats.memory.patternCellsBytes
)}<br />`;
html += ` Memory (Pattern ID Map): ~${formatBytes(
stats.memory.patternByIdBytes
)}<br />`;
html += `<br />`;
html += `<strong>Adjacency Rules (${(
stats.adjacency.timeMs / 1000
).toFixed(2)}s):</strong><br />`;
html += ` Total Adjacency Rules: ${stats.adjacency.totalRules}<br />`;
html += ` Memory (Adjacency Map): ~${formatBytes(
stats.memory.adjacencyBytes
)}<br />`;
html += `<br />`;
html += `<strong>Output Grid (${(stats.grid.timeMs / 1000).toFixed(
2
)}s initialization):</strong><br />`;
html += ` Output Grid Pixel Size: ${OUTPUT_SIZE * currentPatternSize}x${
OUTPUT_SIZE * currentPatternSize
} (pixels)<br />`;
html += ` Pattern Grid Dimensions: ${stats.grid.pCols}x${
stats.grid.pRows
} (${stats.grid.pCols * stats.grid.pRows} cells)<br />`;
html += ` Initial Options/Cell: ${stats.grid.initialCellOptions}<br />`;
html += ` Est. Memory per Grid State (Solver): ~${formatBytes(
stats.memory.estimatedGridSnapshotBytes
)}<br />`;
html += `<br />`;
}
html += `<strong>WFC Run Dynamics:</strong><br />`;
html += ` Max Backtrack Stack Depth: ${stats.wfcRun.maxBacktrackStackDepthReached} / ${MAX_BACKTRACK_DEPTH}<br />`;
html += ` Est. Peak Backtrack Stack Memory (Solver): ~${formatBytes(
stats.memory.backtrackStackPeakBytes
)}<br />`;
html += ` Total Backtracks: ${stats.wfcRun.totalBacktracks}<br />`;
html += ` Forced Backtracks (Max Depth): ${stats.wfcRun.forcedBacktracksByDepth}<br />`;
html += ` Total Contradictions: ${stats.wfcRun.totalContradictions}<br />`;
html += ` Cells Collapsed: ${stats.wfcRun.cellsCollapsed}<br />`;
html += ` Propagation Steps: ${stats.wfcRun.propagationSteps}<br />`;
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();
})();

65
style.css Normal file
View File

@ -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 */
}

398
wfc_setup_worker.js Normal file
View File

@ -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
};

641
wfc_solver_worker.js Normal file
View File

@ -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
};