| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Wave Function Collapse (WFC) Demo</title> |
| <style> |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| background-color: #f0f2f5; |
| color: #333; |
| margin: 0; |
| padding: 1rem; |
| } |
| h1 { |
| color: #111; |
| } |
| .main-container { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 2rem; |
| justify-content: center; |
| width: 100%; |
| max-width: 1400px; |
| } |
| .canvas-container { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| padding: 1rem; |
| background: #fff; |
| border-radius: 8px; |
| box-shadow: 0 4px 6px rgba(0,0,0,0.1); |
| } |
| canvas { |
| border: 1px solid #ccc; |
| image-rendering: pixelated; |
| image-rendering: -moz-crisp-edges; |
| image-rendering: crisp-edges; |
| background-color: #e9e9e9; |
| } |
| h2 { |
| margin-top: 0; |
| margin-bottom: 1rem; |
| color: #444; |
| } |
| .controls { |
| padding: 1rem; |
| background: #fff; |
| border-radius: 8px; |
| box-shadow: 0 4px 6px rgba(0,0,0,0.1); |
| display: flex; |
| flex-direction: column; |
| gap: 1rem; |
| min-width: 280px; |
| } |
| .control-group { |
| display: flex; |
| flex-direction: column; |
| gap: 0.5rem; |
| } |
| .control-group label { |
| font-weight: bold; |
| } |
| .control-group input[type="number"], .control-group input[type="checkbox"] { |
| padding: 0.5rem; |
| border: 1px solid #ccc; |
| border-radius: 4px; |
| box-sizing: border-box; |
| width: 100%; |
| } |
| .control-group input[type="checkbox"] { |
| width: 20px; |
| height: 20px; |
| } |
| .checkbox-label { |
| display: flex; |
| align-items: center; |
| gap: 0.5rem; |
| } |
| .palette { |
| display: flex; |
| gap: 0.5rem; |
| flex-wrap: wrap; |
| align-items: center; |
| } |
| .color-swatch { |
| width: 30px; |
| height: 30px; |
| border: 2px solid #ccc; |
| border-radius: 4px; |
| cursor: pointer; |
| transition: transform 0.1s; |
| } |
| .color-swatch.selected { |
| border-color: #007bff; |
| transform: scale(1.1); |
| box-shadow: 0 0 5px #007bff; |
| } |
| input[type="color"] { |
| width: 40px; |
| height: 40px; |
| border: none; |
| padding: 0; |
| background: none; |
| cursor: pointer; |
| } |
| button { |
| padding: 0.75rem 1rem; |
| border: none; |
| border-radius: 4px; |
| cursor: pointer; |
| font-size: 1rem; |
| font-weight: bold; |
| transition: background-color 0.2s; |
| } |
| .btn-generate { |
| background-color: #28a745; |
| color: white; |
| } |
| .btn-generate:hover { |
| background-color: #218838; |
| } |
| .btn-clear { |
| background-color: #dc3545; |
| color: white; |
| } |
| .btn-clear:hover { |
| background-color: #c82333; |
| } |
| #status { |
| margin-top: 1rem; |
| font-weight: bold; |
| font-size: 1.1rem; |
| min-height: 1.5em; |
| } |
| </style> |
| </head> |
| <body> |
|
|
| <h1>Wave Function Collapse</h1> |
| <p>Draw on the left canvas, set your parameters, and click "Generate"!</p> |
|
|
| <div class="main-container"> |
| <div class="controls"> |
| <h2>Controls</h2> |
| <div class="control-group"> |
| <label for="n-size">Pattern Size (N)</label> |
| <input type="number" id="n-size" value="3" min="2" max="5"> |
| </div> |
| <div class="control-group"> |
| <label for="input-dim">Input Grid Size</label> |
| <input type="number" id="input-dim" value="20" min="10" max="40"> |
| </div> |
| <div class="control-group"> |
| <label for="output-width">Output Width</label> |
| <input type="number" id="output-width" value="48" min="10" max="256"> |
| </div> |
| <div class="control-group"> |
| <label for="output-height">Output Height</label> |
| <input type="number" id="output-height" value="48" min="10" max="256"> |
| </div> |
| <div class="control-group"> |
| <label class="checkbox-label"> |
| <input type="checkbox" id="augment" checked> |
| Use Augmentations (Rotations/Reflections) |
| </label> |
| </div> |
| <div class="control-group"> |
| <label class="checkbox-label"> |
| <input type="checkbox" id="visualize"> |
| Visualize Generation |
| </label> |
| </div> |
| <div class="control-group"> |
| <label>Color Palette</label> |
| <div class="palette"> |
| <div id="palette-container"></div> |
| <input type="color" id="color-picker" value="#ff0000"> |
| </div> |
| </div> |
| <button class="btn-generate" id="generate-btn">Generate</button> |
| <button class="btn-clear" id="clear-btn">Clear Input</button> |
| <div id="status">Ready.</div> |
| </div> |
|
|
| <div class="canvas-container"> |
| <h2>Input Canvas</h2> |
| <canvas id="input-canvas" width="300" height="300"></canvas> |
| </div> |
|
|
| <div class="canvas-container"> |
| <h2>Output Canvas</h2> |
| <canvas id="output-canvas" width="480" height="480"></canvas> |
| </div> |
| </div> |
|
|
| <script> |
| document.addEventListener('DOMContentLoaded', () => { |
| |
| const inputCanvas = document.getElementById('input-canvas'); |
| const inputCtx = inputCanvas.getContext('2d'); |
| const outputCanvas = document.getElementById('output-canvas'); |
| const outputCtx = outputCanvas.getContext('2d'); |
| const nSizeInput = document.getElementById('n-size'); |
| const inputDimInput = document.getElementById('input-dim'); |
| const outputWidthInput = document.getElementById('output-width'); |
| const outputHeightInput = document.getElementById('output-height'); |
| const augmentCheckbox = document.getElementById('augment'); |
| const visualizeCheckbox = document.getElementById('visualize'); |
| const generateBtn = document.getElementById('generate-btn'); |
| const clearBtn = document.getElementById('clear-btn'); |
| const statusDiv = document.getElementById('status'); |
| const paletteContainer = document.getElementById('palette-container'); |
| const colorPicker = document.getElementById('color-picker'); |
| |
| |
| let inputDim = parseInt(inputDimInput.value); |
| let pixelSize = inputCanvas.width / inputDim; |
| let isDrawing = false; |
| |
| let colors = ['#ffffff', '#000000', '#f44336', '#ffeb3b', '#4caf50', '#2196f3', '#9c27b0', '#ff9800']; |
| let currentColorIndex = 1; |
| let inputGrid = []; |
| |
| |
| const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); |
| |
| |
| function updatePalette() { |
| paletteContainer.innerHTML = ''; |
| colors.forEach((color, index) => { |
| const swatch = document.createElement('div'); |
| swatch.className = 'color-swatch'; |
| swatch.style.backgroundColor = color; |
| if (index === currentColorIndex) { |
| swatch.classList.add('selected'); |
| } |
| swatch.addEventListener('click', () => { |
| currentColorIndex = index; |
| updatePalette(); |
| }); |
| paletteContainer.appendChild(swatch); |
| }); |
| } |
| |
| colorPicker.addEventListener('change', (e) => { |
| const newColor = e.target.value; |
| if (!colors.includes(newColor)) { |
| colors.push(newColor); |
| currentColorIndex = colors.length - 1; |
| updatePalette(); |
| } |
| }); |
| |
| function drawInputGrid() { |
| inputCtx.clearRect(0, 0, inputCanvas.width, inputCanvas.height); |
| for (let y = 0; y < inputDim; y++) { |
| for (let x = 0; x < inputDim; x++) { |
| inputCtx.fillStyle = colors[inputGrid[y][x]]; |
| inputCtx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize); |
| } |
| } |
| |
| inputCtx.strokeStyle = '#ddd'; |
| inputCtx.lineWidth = 0.5; |
| for (let i = 0; i <= inputDim; i++) { |
| inputCtx.beginPath(); |
| inputCtx.moveTo(i * pixelSize, 0); |
| inputCtx.lineTo(i * pixelSize, inputCanvas.height); |
| inputCtx.stroke(); |
| inputCtx.beginPath(); |
| inputCtx.moveTo(0, i * pixelSize); |
| inputCtx.lineTo(inputCanvas.width, i * pixelSize); |
| inputCtx.stroke(); |
| } |
| } |
| |
| |
| function resetInputCanvas() { |
| inputDim = parseInt(inputDimInput.value); |
| pixelSize = inputCanvas.width / inputDim; |
| inputGrid = Array.from({ length: inputDim }, () => Array(inputDim).fill(0)); |
| drawInputGrid(); |
| } |
| |
| function handleDraw(event) { |
| if (!isDrawing) return; |
| const rect = inputCanvas.getBoundingClientRect(); |
| const x = Math.floor((event.clientX - rect.left) / pixelSize); |
| const y = Math.floor((event.clientY - rect.top) / pixelSize); |
| if (x >= 0 && x < inputDim && y >= 0 && y < inputDim) { |
| if (inputGrid[y][x] !== currentColorIndex) { |
| inputGrid[y][x] = currentColorIndex; |
| drawInputGrid(); |
| } |
| } |
| } |
| |
| inputCanvas.addEventListener('mousedown', (e) => { isDrawing = true; handleDraw(e); }); |
| inputCanvas.addEventListener('mouseup', () => { isDrawing = false; }); |
| inputCanvas.addEventListener('mouseleave', () => { isDrawing = false; }); |
| inputCanvas.addEventListener('mousemove', handleDraw); |
| |
| |
| inputDimInput.addEventListener('change', resetInputCanvas); |
| clearBtn.addEventListener('click', resetInputCanvas); |
| |
| |
| function createDefaultPattern() { |
| inputGrid = Array.from({ length: inputDim }, () => Array(inputDim).fill(0)); |
| const center = Math.floor(inputDim / 2); |
| const start = Math.floor(inputDim / 4); |
| const end = inputDim - start; |
| for(let i = start; i < end; i++) { |
| inputGrid[center][i] = 1; |
| inputGrid[i][center] = 2; |
| } |
| inputGrid[center][center] = 3; |
| } |
| |
| |
| |
| class WFC { |
| constructor(inputGrid, N, outputWidth, outputHeight, useAugmentations, visualizeCallback) { |
| this.N = N; |
| this.outputWidth = outputWidth; |
| this.outputHeight = outputHeight; |
| this.useAugmentations = useAugmentations; |
| this.visualizeCallback = visualizeCallback; |
| |
| this.patterns = []; |
| this.patternWeights = {}; |
| |
| this.parseInput(inputGrid); |
| this.buildAdjacency(); |
| |
| this.wave = []; |
| this.entropy = []; |
| this.allPatternIndices = Array.from({ length: this.patterns.length }, (_, i) => i); |
| |
| for (let y = 0; y < outputHeight; y++) { |
| this.wave[y] = []; |
| this.entropy[y] = []; |
| for (let x = 0; x < outputWidth; x++) { |
| this.wave[y][x] = [...this.allPatternIndices]; |
| this.entropy[y][x] = this.patterns.length; |
| } |
| } |
| } |
| parseInput(grid) { |
| const patternMap = new Map(); |
| const gridHeight = grid.length; |
| const gridWidth = grid[0].length; |
| |
| for (let y = 0; y <= gridHeight - this.N; y++) { |
| for (let x = 0; x <= gridWidth - this.N; x++) { |
| const pattern = []; |
| for (let dy = 0; dy < this.N; dy++) { |
| pattern.push(...grid[y + dy].slice(x, x + this.N)); |
| } |
| |
| if (this.useAugmentations) { |
| const augmentations = this.getAugmentations(pattern); |
| augmentations.forEach(p => { |
| const hash = p.join(','); |
| patternMap.set(hash, (patternMap.get(hash) || 0) + 1); |
| }); |
| } else { |
| const hash = pattern.join(','); |
| patternMap.set(hash, (patternMap.get(hash) || 0) + 1); |
| } |
| } |
| } |
| |
| patternMap.forEach((weight, hash) => { |
| const pattern = hash.split(',').map(Number); |
| this.patterns.push(pattern); |
| this.patternWeights[this.patterns.length - 1] = weight; |
| }); |
| |
| if (this.patterns.length === 0) { |
| throw new Error("No patterns found. Try a larger or more varied input, or a smaller N."); |
| } |
| } |
| getAugmentations(pattern) { |
| const augmentations = new Set(); |
| let current = pattern; |
| for (let i = 0; i < 4; i++) { |
| augmentations.add(current.join(',')); |
| augmentations.add(this.reflect(current).join(',')); |
| current = this.rotate(current); |
| } |
| return Array.from(augmentations).map(hash => hash.split(',').map(Number)); |
| } |
| rotate(p) { |
| const newPattern = Array(this.N * this.N); |
| for (let y = 0; y < this.N; y++) { |
| for (let x = 0; x < this.N; x++) { |
| newPattern[x * this.N + (this.N - 1 - y)] = p[y * this.N + x]; |
| } |
| } |
| return newPattern; |
| } |
| reflect(p) { |
| const newPattern = Array(this.N * this.N); |
| for (let y = 0; y < this.N; y++) { |
| for (let x = 0; x < this.N; x++) { |
| newPattern[y * this.N + (this.N - 1 - x)] = p[y * this.N + x]; |
| } |
| } |
| return newPattern; |
| } |
| buildAdjacency() { |
| this.adjacency = {}; |
| for (let i = 0; i < this.patterns.length; i++) { |
| this.adjacency[i] = { '1,0': [], '-1,0': [], '0,1': [], '0,-1': [] }; |
| } |
| for (let i = 0; i < this.patterns.length; i++) { |
| for (let j = 0; j < this.patterns.length; j++) { |
| if (this.checkOverlap(this.patterns[i], this.patterns[j], 1, 0)) this.adjacency[i]['1,0'].push(j); |
| if (this.checkOverlap(this.patterns[i], this.patterns[j], -1, 0)) this.adjacency[i]['-1,0'].push(j); |
| if (this.checkOverlap(this.patterns[i], this.patterns[j], 0, 1)) this.adjacency[i]['0,1'].push(j); |
| if (this.checkOverlap(this.patterns[i], this.patterns[j], 0, -1)) this.adjacency[i]['0,-1'].push(j); |
| } |
| } |
| } |
| checkOverlap(p1, p2, dx, dy) { |
| for (let y = 0; y < this.N; y++) { |
| for (let x = 0; x < this.N; x++) { |
| const nx = x - dx; |
| const ny = y - dy; |
| if (nx >= 0 && nx < this.N && ny >= 0 && ny < this.N) { |
| if (p1[y * this.N + x] !== p2[ny * this.N + nx]) return false; |
| } |
| } |
| } |
| return true; |
| } |
| async run() { |
| let iterations = this.outputWidth * this.outputHeight; |
| while(iterations > 0) { |
| const collapsedCoords = this.observe(); |
| if (collapsedCoords === null) return this.generateOutput(); |
| const contradiction = this.propagate(collapsedCoords); |
| if (contradiction) throw new Error("Contradiction reached. Cannot continue."); |
| iterations--; |
| if (this.visualizeCallback) { |
| await this.visualizeCallback(this.wave, this.patterns.length); |
| await sleep(0); |
| } |
| } |
| return this.generateOutput(); |
| } |
| observe() { |
| let minEntropy = Infinity; |
| let minCoords = null; |
| for (let y = 0; y < this.outputHeight; y++) { |
| for (let x = 0; x < this.outputWidth; x++) { |
| const possibilities = this.wave[y][x].length; |
| if (possibilities > 1 && possibilities < minEntropy) { |
| minEntropy = possibilities; |
| minCoords = [{x, y}]; |
| } else if (possibilities > 1 && possibilities === minEntropy) { |
| minCoords.push({x, y}); |
| } |
| } |
| } |
| if (minCoords === null) return null; |
| const choice = minCoords[Math.floor(Math.random() * minCoords.length)]; |
| const {x, y} = choice; |
| const possiblePatterns = this.wave[y][x]; |
| const totalWeight = possiblePatterns.reduce((sum, pIndex) => sum + this.patternWeights[pIndex], 0); |
| let rnd = Math.random() * totalWeight; |
| let chosenPattern; |
| for(const pIndex of possiblePatterns) { |
| rnd -= this.patternWeights[pIndex]; |
| if(rnd <= 0) { |
| chosenPattern = pIndex; |
| break; |
| } |
| } |
| this.wave[y][x] = [chosenPattern]; |
| return {x, y}; |
| } |
| propagate(startCoords) { |
| const stack = [startCoords]; |
| while(stack.length > 0) { |
| const {x, y} = stack.pop(); |
| const currentPatterns = this.wave[y][x]; |
| for (const [dx, dy] of [[0,1], [0,-1], [1,0], [-1,0]]) { |
| const nx = x + dx; |
| const ny = y + dy; |
| if (nx < 0 || nx >= this.outputWidth || ny < 0 || ny >= this.outputHeight) continue; |
| let neighborPatterns = this.wave[ny][nx]; |
| if(neighborPatterns.length <= 1) continue; |
| const validNeighboringPatterns = new Set(); |
| const directionKey = `${-dx},${-dy}`; |
| for (const pIndex of currentPatterns) { |
| this.adjacency[pIndex][directionKey].forEach(validIndex => validNeighboringPatterns.add(validIndex)); |
| } |
| const originalLength = neighborPatterns.length; |
| const newNeighborPatterns = neighborPatterns.filter(pIndex => validNeighboringPatterns.has(pIndex)); |
| if (newNeighborPatterns.length === 0) return true; |
| if (newNeighborPatterns.length < originalLength) { |
| this.wave[ny][nx] = newNeighborPatterns; |
| stack.push({x: nx, y: ny}); |
| } |
| } |
| } |
| return false; |
| } |
| generateOutput() { |
| const outputGrid = Array.from({ length: this.outputHeight }, () => Array(this.outputWidth)); |
| for (let y = 0; y < this.outputHeight; y++) { |
| for (let x = 0; x < this.outputWidth; x++) { |
| if (this.wave[y][x].length > 0) { |
| const pattern = this.patterns[this.wave[y][x][0]]; |
| outputGrid[y][x] = pattern[0]; |
| } else { |
| outputGrid[y][x] = 0; |
| } |
| } |
| } |
| return outputGrid; |
| } |
| } |
| |
| |
| |
| async function handleGenerate() { |
| generateBtn.disabled = true; |
| statusDiv.textContent = 'Starting...'; |
| statusDiv.style.color = '#333'; |
| |
| const outputWidth = parseInt(outputWidthInput.value); |
| const outputHeight = parseInt(outputHeightInput.value); |
| outputCanvas.width = outputWidth * 10; |
| outputCanvas.height = outputHeight * 10; |
| outputCtx.clearRect(0, 0, outputCanvas.width, outputCanvas.height); |
| |
| await sleep(10); |
| |
| try { |
| statusDiv.textContent = '1. Parsing input patterns...'; |
| const N = parseInt(nSizeInput.value); |
| const useAugmentations = augmentCheckbox.checked; |
| const visualize = visualizeCheckbox.checked; |
| |
| let visualizeCallback = null; |
| if (visualize) { |
| visualizeCallback = (wave, maxEntropy) => { |
| const pixelW = outputCanvas.width / outputWidth; |
| const pixelH = outputCanvas.height / outputHeight; |
| outputCtx.clearRect(0, 0, outputCanvas.width, outputCanvas.height); |
| for(let y = 0; y < outputHeight; y++) { |
| for (let x = 0; x < outputWidth; x++) { |
| const entropy = wave[y][x].length; |
| if(entropy === 1) { |
| const colorIndex = wfc.patterns[wave[y][x][0]][0]; |
| outputCtx.fillStyle = colors[colorIndex]; |
| } else { |
| const grayscale = Math.floor(255 * (1 - (entropy / maxEntropy))); |
| outputCtx.fillStyle = `rgb(${grayscale}, ${grayscale}, ${grayscale})`; |
| } |
| outputCtx.fillRect(x * pixelW, y * pixelH, pixelW, pixelH); |
| } |
| } |
| }; |
| } |
| |
| const wfc = new WFC(inputGrid, N, outputWidth, outputHeight, useAugmentations, visualizeCallback); |
| |
| statusDiv.textContent = '2. Running WFC algorithm...'; |
| await sleep(10); |
| |
| const resultGrid = await wfc.run(); |
| |
| statusDiv.textContent = '3. Rendering output...'; |
| await sleep(10); |
| |
| const pixelW = outputCanvas.width / outputWidth; |
| const pixelH = outputCanvas.height / outputHeight; |
| outputCtx.clearRect(0, 0, outputCanvas.width, outputCanvas.height); |
| for(let y = 0; y < outputHeight; y++) { |
| for (let x = 0; x < outputWidth; x++) { |
| const colorIndex = resultGrid[y][x]; |
| outputCtx.fillStyle = colors[colorIndex] || '#ff00ff'; |
| outputCtx.fillRect(x * pixelW, y * pixelH, pixelW, pixelH); |
| } |
| } |
| |
| statusDiv.textContent = 'Finished successfully!'; |
| statusDiv.style.color = 'green'; |
| |
| } catch (error) { |
| console.error(error); |
| statusDiv.textContent = `Error: ${error.message}`; |
| statusDiv.style.color = 'red'; |
| } finally { |
| generateBtn.disabled = false; |
| } |
| } |
| |
| generateBtn.addEventListener('click', handleGenerate); |
| |
| |
| function initialize() { |
| updatePalette(); |
| createDefaultPattern(); |
| drawInputGrid(); |
| } |
| |
| initialize(); |
| }); |
| </script> |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=EnovinxSchool/wave-collapse-function-not-designed-by-me" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> |
| </html> |