diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 857a8ff..ab7169d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,9 +32,13 @@ concurrency: cancel-in-progress: false jobs: + # This job builds and deploys the site to GitHub Pages. deploy: environment: name: github-pages + # The URL for the deployed site will be available as an output of the deployment step. + # For the `dev` branch, this will be a unique preview URL for that specific deployment. + # You can find this URL in the summary of the workflow run in the "Actions" tab. url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: @@ -46,8 +50,11 @@ jobs: ref: ${{ github.event.inputs.branch || github.ref_name }} - name: Setup Pages uses: actions/configure-pages@v4 + with: + # Automatically enables Pages for the repository + enablement: true - name: Upload artifact - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v4 with: # Upload entire repository path: '.' diff --git a/index.html b/index.html index 8406590..64e7529 100644 --- a/index.html +++ b/index.html @@ -31,7 +31,7 @@

Controls

- +
@@ -41,6 +41,10 @@

Controls

Original Image

+
+

Processed Image

+ +

String Art

@@ -51,6 +55,16 @@

String Art

Instructions

    + + diff --git a/jules-scratch/verification/verify_intelligent_stop.py b/jules-scratch/verification/verify_intelligent_stop.py new file mode 100644 index 0000000..7df5df5 --- /dev/null +++ b/jules-scratch/verification/verify_intelligent_stop.py @@ -0,0 +1,62 @@ +import os +from playwright.sync_api import sync_playwright, expect + +def run_verification(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + + base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + file_path = os.path.join(base_dir, 'index.html') + + page.goto(f'file://{file_path}') + page.wait_for_load_state('domcontentloaded') + + # --- Test the "Intelligent Stop" feature --- + page.evaluate(""" + // Manually set canvas size, which normally happens on image load + const canvas = document.getElementById('string-art-canvas'); + canvas.width = 400; + canvas.height = 400; + + // Mock the processedImageData with a completely white image + const size = 200; + const mockData = new Uint8ClampedArray(size * size * 4); + // Inverted grayscale: 0 = white for the algorithm, so all scores will be 0 + mockData.fill(0); + window.processedImageData = new ImageData(mockData, size, size); + """) + + # Set a high thread count that should not be reached + page.locator("#threads-input").fill("5000") + + # Listen for the console message that indicates an early stop + message_found = False + def handle_console(msg): + nonlocal message_found + if "Stopping early" in msg.text: + message_found = True + + page.on("console", handle_console) + + # Click the generate button + page.get_by_role("button", name="Generate").click() + + # Wait for the progress bar to disappear, indicating completion + expect(page.locator("#progress-overlay")).to_be_hidden(timeout=10000) + + # Assert that the early stop message was logged + assert message_found, "The 'Intelligent Stop' feature did not trigger as expected." + + # Assert that no instructions were generated because the image was blank + expect(page.locator("#instructions-list li")).to_have_count(0) + + # Take a screenshot for final verification + screenshot_path = "jules-scratch/verification/intelligent_stop_test.png" + page.screenshot(path=screenshot_path) + + browser.close() + print(f"Screenshot saved to {screenshot_path}") + +if __name__ == "__main__": + run_verification() \ No newline at end of file diff --git a/script.js b/script.js index 2d24f68..3c74d39 100644 --- a/script.js +++ b/script.js @@ -1,3 +1,5 @@ +let processedImageData = null; + document.addEventListener('DOMContentLoaded', () => { const imageUpload = document.getElementById('image-upload'); const shapeSelect = document.getElementById('shape-select'); @@ -6,12 +8,16 @@ document.addEventListener('DOMContentLoaded', () => { const generateBtn = document.getElementById('generate-btn'); const originalCanvas = document.getElementById('original-canvas'); const stringArtCanvas = document.getElementById('string-art-canvas'); + const processedCanvas = document.getElementById('processed-canvas'); const instructionsList = document.getElementById('instructions-list'); + const progressOverlay = document.getElementById('progress-overlay'); + const progressBar = document.getElementById('progress-bar'); + const progressText = document.getElementById('progress-text'); const originalCtx = originalCanvas.getContext('2d'); const stringArtCtx = stringArtCanvas.getContext('2d'); + const processedCtx = processedCanvas.getContext('2d'); - let processedImageData = null; let image = new Image(); imageUpload.addEventListener('change', (e) => { @@ -56,14 +62,31 @@ document.addEventListener('DOMContentLoaded', () => { const imageData = tempCtx.getImageData(0, 0, size, size); const data = imageData.data; + // Create a separate grayscale image for display + const displayImageData = new ImageData(size, size); + const displayData = displayImageData.data; + for (let i = 0; i < data.length; i += 4) { const avg = (data[i] + data[i + 1] + data[i + 2]) / 3; - const grayscale = 255 - avg; // Invert so darkness is higher value - data[i] = grayscale; - data[i + 1] = grayscale; - data[i + 2] = grayscale; + // For the algorithm, we want inverted (darkness = high value) + const invertedGrayscale = 255 - avg; + data[i] = invertedGrayscale; + data[i + 1] = invertedGrayscale; + data[i + 2] = invertedGrayscale; + + // For display, we want normal grayscale + displayData[i] = avg; + displayData[i + 1] = avg; + displayData[i + 2] = avg; + displayData[i + 3] = 255; // Alpha } processedImageData = imageData; + + // Display the processed image + processedCanvas.width = size; + processedCanvas.height = size; + processedCtx.putImageData(displayImageData, 0, 0); + console.log("Image processed"); } @@ -95,12 +118,26 @@ document.addEventListener('DOMContentLoaded', () => { } - function generateStringArt() { + // Helper to run tasks asynchronously in chunks to avoid freezing the UI + function runAsyncTask(task) { + return new Promise(resolve => { + setTimeout(() => { + resolve(task()); + }, 0); + }); + } + + + async function generateStringArt() { if (!processedImageData) { alert('Please upload an image first.'); return; } + // --- Show progress bar and disable button --- + generateBtn.disabled = true; + progressOverlay.classList.remove('hidden'); + const numPins = parseInt(pinsInput.value); const numThreads = parseInt(threadsInput.value); const shape = shapeSelect.value; @@ -112,7 +149,6 @@ document.addEventListener('DOMContentLoaded', () => { const pins = getPinCoordinates(numPins, shape, w, h); - // Draw pins stringArtCtx.fillStyle = 'black'; pins.forEach(pin => { stringArtCtx.beginPath(); @@ -120,63 +156,97 @@ document.addEventListener('DOMContentLoaded', () => { stringArtCtx.fill(); }); - - // Create a copy of the image data for the algorithm to modify const imgDataCopy = new Uint8ClampedArray(processedImageData.data); const imgWidth = processedImageData.width; let currentPinIndex = 0; - const path = [0]; const instructions = []; + const lineMemory = new Set(); // To prevent drawing the same line twice stringArtCtx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; stringArtCtx.lineWidth = 0.5; - for (let i = 0; i < numThreads; i++) { let bestNextPin = -1; - let maxDarkness = -Infinity; - - for (let nextPinIndex = 0; nextPinIndex < numPins; nextPinIndex++) { - // Don't connect to self or immediate neighbors for better patterns - if (nextPinIndex === currentPinIndex || Math.abs(nextPinIndex - currentPinIndex) < 5) { - continue; + let maxDarkness = 0; // Initialize to 0, not -Infinity + + // We wrap the inner loop in an async task to allow UI updates + await runAsyncTask(() => { + for (let nextPinIndex = 0; nextPinIndex < numPins; nextPinIndex++) { + // Don't connect to self + if (nextPinIndex === currentPinIndex) continue; + + // Create a unique key for the pin pair, order doesn't matter + const lineKey = `${Math.min(currentPinIndex, nextPinIndex)}-${Math.max(currentPinIndex, nextPinIndex)}`; + if (lineMemory.has(lineKey)) { + continue; // Skip if we've already drawn this line + } + + const linePixels = getLinePixels(pins[currentPinIndex], pins[nextPinIndex], w, h, imgWidth, imgWidth); + let currentDarkness = 0; + for (const pixel of linePixels) { + const index = (pixel.y * imgWidth + pixel.x) * 4; + currentDarkness += imgDataCopy[index]; + } + + // --- Improved Scoring --- + // Favor longer lines to avoid getting stuck + const dx = pins[currentPinIndex].x - pins[nextPinIndex].x; + const dy = pins[currentPinIndex].y - pins[nextPinIndex].y; + const length = Math.sqrt(dx * dx + dy * dy); + + // The score is a combination of darkness and length + // The exponent on length gives it more weight, preventing short, repetitive lines. + const score = currentDarkness * Math.pow(length, 0.5); + + if (score > maxDarkness) { + maxDarkness = score; + bestNextPin = nextPinIndex; + } } + }); - const linePixels = getLinePixels(pins[currentPinIndex], pins[nextPinIndex], w, h, imgWidth, imgWidth); - let currentDarkness = 0; - for (const pixel of linePixels) { - const index = (pixel.y * imgWidth + pixel.x) * 4; - currentDarkness += imgDataCopy[index]; - } - if (currentDarkness > maxDarkness) { - maxDarkness = currentDarkness; - bestNextPin = nextPinIndex; - } + // --- Intelligent Stop --- + // If the best score is very low, it means there are no more good lines to draw. + // A threshold of 1 is arbitrary but works as a floor to prevent drawing "nothing" lines. + if (bestNextPin === -1 || maxDarkness < 1) { + console.log(`Stopping early at thread ${i} because no good lines were found.`); + break; // Exit the loop } if (bestNextPin !== -1) { - // Draw the line on the canvas stringArtCtx.beginPath(); stringArtCtx.moveTo(pins[currentPinIndex].x, pins[currentPinIndex].y); stringArtCtx.lineTo(pins[bestNextPin].x, pins[bestNextPin].y); stringArtCtx.stroke(); - // "Remove" the darkness from the image copy const bestLinePixels = getLinePixels(pins[currentPinIndex], pins[bestNextPin], w, h, imgWidth, imgWidth); for (const pixel of bestLinePixels) { const index = (pixel.y * imgWidth + pixel.x) * 4; - imgDataCopy[index] = 0; - imgDataCopy[index + 1] = 0; - imgDataCopy[index + 2] = 0; + // "Bleach" the line by reducing darkness + imgDataCopy[index] = Math.max(0, imgDataCopy[index] - 64); + imgDataCopy[index + 1] = Math.max(0, imgDataCopy[index + 1] - 64); + imgDataCopy[index + 2] = Math.max(0, imgDataCopy[index + 2] - 64); } instructions.push({ from: currentPinIndex + 1, to: bestNextPin + 1 }); + const lineKey = `${Math.min(currentPinIndex, bestNextPin)}-${Math.max(currentPinIndex, bestNextPin)}`; + lineMemory.add(lineKey); // Remember this line currentPinIndex = bestNextPin; - path.push(currentPinIndex); + } + + // --- Update progress bar --- + const progress = ((i + 1) / numThreads) * 100; + progressBar.style.width = `${progress}%`; + progressText.textContent = `${Math.round(progress)}%`; } + + // --- Hide progress bar and re-enable button --- + generateBtn.disabled = false; + progressOverlay.classList.add('hidden'); + displayInstructions(instructions); } @@ -220,5 +290,7 @@ document.addEventListener('DOMContentLoaded', () => { }); } - generateBtn.addEventListener('click', generateStringArt); + generateBtn.addEventListener('click', async () => { + await generateStringArt(); + }); }); \ No newline at end of file diff --git a/style.css b/style.css index 13d488b..5892bc3 100644 --- a/style.css +++ b/style.css @@ -30,9 +30,10 @@ main { } .output { - flex: 2 1 500px; + flex: 3 1 600px; display: flex; gap: 1rem; + justify-content: space-around; } .instructions { @@ -79,4 +80,45 @@ canvas { padding-left: 20px; max-height: 400px; overflow-y: auto; +} + +/* Progress Bar Styles */ +#progress-overlay.hidden { + display: none; +} + +#progress-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + display: flex; + justify-content: center; + align-items: center; +} + +.progress-container { + background-color: white; + padding: 2rem; + border-radius: 5px; + text-align: center; + width: 300px; +} + +.progress-bar-container { + width: 100%; + background-color: #e0e0e0; + border-radius: 5px; + margin: 1rem 0; +} + +.progress-bar { + width: 0%; + height: 20px; + background-color: #4caf50; + border-radius: 5px; + transition: width 0.1s linear; } \ No newline at end of file diff --git a/tests/test.js b/tests/test.js index c3f0174..d26b7d6 100644 --- a/tests/test.js +++ b/tests/test.js @@ -79,6 +79,10 @@ document.addEventListener('DOMContentLoaded', () => { // This is a simplified approach. For a real app, a library like Jest with JSDOM would be better. if (!document.getElementById('image-upload')) { document.body.innerHTML += ` + @@ -86,7 +90,36 @@ document.addEventListener('DOMContentLoaded', () => { +
      `; } + + runTest('generateStringArt: Runs to completion', async () => { + // Mock the processedImageData + const size = 20; + const mockData = new Uint8ClampedArray(size * size * 4); + for (let i = 0; i < mockData.length; i += 4) { + mockData[i] = 128; // Some gray value + mockData[i+1] = 128; + mockData[i+2] = 128; + mockData[i+3] = 255; + } + window.processedImageData = new ImageData(mockData, size, size); + + // Set some input values + document.getElementById('pins-input').value = '50'; + document.getElementById('threads-input').value = '100'; + + // Get a reference to the instructions list + const instructionsList = document.getElementById('instructions-list'); + instructionsList.innerHTML = ''; // Clear it before the test + + // Run the function + await generateStringArt(); + + // Assert that instructions were generated + assert(instructionsList.children.length > 0, 'Should generate more than 0 instructions'); + assertEquals(100, instructionsList.children.length, 'Should generate the requested number of instructions'); + }); }); \ No newline at end of file