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
+
+
+
+
Generating String Art...
+
+
+
+
0%
+
+
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