Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/main/java/com/bobrust/generator/sorter/BorstSorter.java
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,20 @@ public static BlobList sort(BlobList data, int size) {
blobs.addAll(Arrays.asList(sort0(pieces, size, localMap)));
}

BlobList result = new BlobList(blobs);

// Apply 2-opt local search to reduce palette changes + travel distance
if (AppConstants.USE_TSP_OPTIMIZATION && result.size() > 2) {
Comment on lines +155 to +156
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BorstSorter.sort() now runs 2-opt on the entire concatenated result list. Because the 2-opt implementation is O(n²) per iteration, this can become prohibitively slow (or effectively hang) when data.size() is large (the sorter already chunks work via MAX_SORT_GROUP, but this optimization ignores that and reintroduces a quadratic pass over all blobs). Consider capping optimization to a maximum N, running it per chunk/window, or making optimization opt-in at call sites for large runs.

Suggested change
// Apply 2-opt local search to reduce palette changes + travel distance
if (AppConstants.USE_TSP_OPTIMIZATION && result.size() > 2) {
// Apply 2-opt local search to reduce palette changes + travel distance.
// Cap optimization to the existing chunk size so large runs do not
// reintroduce an expensive whole-list quadratic pass.
if (AppConstants.USE_TSP_OPTIMIZATION
&& result.size() > 2
&& result.size() <= AppConstants.MAX_SORT_GROUP) {

Copilot uses AI. Check for mistakes.
TwoOptOptimizer optimizer = new TwoOptOptimizer(size, size);
result = optimizer.optimize(result);
}

if (AppConstants.DEBUG_TIME) {
long time = System.nanoTime() - start;
AppConstants.LOGGER.info("BorstSorter.sort(data, size) took {} ms for {} shapes", time / 1000000.0, data.size());
}

return new BlobList(blobs);
return result;
}

private static Blob[] sort0(Piece[] array, int size, IntList[] map) {
Expand Down
140 changes: 140 additions & 0 deletions src/main/java/com/bobrust/generator/sorter/TwoOptOptimizer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package com.bobrust.generator.sorter;

import com.bobrust.util.data.AppConstants;

/**
* 2-opt local search optimizer for paint ordering.
*
* Takes the greedy output from {@link BorstSorter} and applies 2-opt
* improvements to reduce total cost, which is a weighted sum of palette
* changes and Euclidean cursor travel distance.
*
* The cost function:
* cost(a, b) = W_palette * paletteChanges(a, b) + W_distance * normalizedDistance(a, b)
*/
public class TwoOptOptimizer {

/**
* The maximum Euclidean distance used for normalization.
* For a typical sign this is the diagonal of the canvas.
*/
private final float maxDistance;

public TwoOptOptimizer(int canvasWidth, int canvasHeight) {
this.maxDistance = (float) Math.sqrt(
(double) canvasWidth * canvasWidth + (double) canvasHeight * canvasHeight);
}

/**
* Compute the cost of transitioning from blob a to blob b.
*/
public double cost(Blob a, Blob b) {
int paletteChanges = countPaletteChanges(a, b);
float distance = euclideanDistance(a, b);
float normalizedDist = (maxDistance > 0) ? distance / maxDistance : 0;

return AppConstants.TSP_W_PALETTE * paletteChanges
+ AppConstants.TSP_W_DISTANCE * normalizedDist;
}

/**
* Count the number of palette interaction changes between two consecutive blobs.
* Each differing attribute (size, color, alpha, shape) requires a click action.
*/
public static int countPaletteChanges(Blob a, Blob b) {
int changes = 0;
if (a.sizeIndex != b.sizeIndex) changes++;
if (a.colorIndex != b.colorIndex) changes++;
if (a.alphaIndex != b.alphaIndex) changes++;
if (a.shapeIndex != b.shapeIndex) changes++;
return changes;
}

/**
* Euclidean distance between the centers of two blobs.
*/
public static float euclideanDistance(Blob a, Blob b) {
int dx = a.x - b.x;
int dy = a.y - b.y;
return (float) Math.sqrt((double) dx * dx + (double) dy * dy);
}

/**
* Compute the total route cost for an ordered array of blobs.
*/
public double totalCost(Blob[] blobs) {
if (blobs.length <= 1) return 0;
double total = 0;
for (int i = 0; i < blobs.length - 1; i++) {
total += cost(blobs[i], blobs[i + 1]);
}
return total;
}

/**
* Apply 2-opt local search to improve the ordering.
* For each pair of edges (i, i+1) and (j, j+1), checks if reversing
* the segment [i+1..j] reduces total cost.
*
* @param blobs the ordered blob array to optimize in-place
* @return the optimized array (same reference)
*/
public Blob[] optimize(Blob[] blobs) {
if (blobs.length <= 3) return blobs;

boolean improved = true;
int maxIterations = 100; // Safety limit to prevent excessive optimization time
int iteration = 0;

while (improved && iteration < maxIterations) {
improved = false;
iteration++;

for (int i = 0; i < blobs.length - 2; i++) {
for (int j = i + 2; j < blobs.length - 1; j++) {
// Current edges: (i, i+1) and (j, j+1)
// Proposed: reverse segment [i+1..j]
// New edges: (i, j) and (i+1, j+1)
double oldCost = cost(blobs[i], blobs[i + 1])
+ cost(blobs[j], blobs[j + 1]);
double newCost = cost(blobs[i], blobs[j])
+ cost(blobs[i + 1], blobs[j + 1]);

if (newCost < oldCost - 1e-10) {
// Reverse the segment [i+1..j]
reverse(blobs, i + 1, j);
improved = true;
}
}
}
}

return blobs;
}

/**
* Apply 2-opt optimization to a BlobList and return a new optimized BlobList.
*/
public BlobList optimize(BlobList sorted) {
Blob[] blobs = sorted.getList().toArray(new Blob[0]);
optimize(blobs);
BlobList result = new BlobList();
for (Blob b : blobs) {
result.add(b);
}
return result;
}

/**
* Reverse the sub-array blobs[start..end] inclusive.
*/
private static void reverse(Blob[] blobs, int start, int end) {
while (start < end) {
Blob temp = blobs[start];
blobs[start] = blobs[end];
blobs[end] = temp;
start++;
end--;
}
}
}
8 changes: 8 additions & 0 deletions src/main/java/com/bobrust/util/data/AppConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ public interface AppConstants {
// When true, use batch-parallel energy evaluation with combined color+energy pass,
// spatial batching for cache locality, and precomputed alpha blend tables
boolean USE_BATCH_PARALLEL = true;

// When true, apply 2-opt local search on top of greedy BorstSorter output
// to reduce total cost (palette changes + cursor travel distance)
boolean USE_TSP_OPTIMIZATION = true;

// TSP cost function weights
float TSP_W_PALETTE = 3.0f; // Weight for palette change cost
float TSP_W_DISTANCE = 1.0f; // Weight for Euclidean distance cost

// Average canvas colors. Used as default colors
Color CANVAS_AVERAGE = new Color(0xb3aba0);
Expand Down
221 changes: 221 additions & 0 deletions src/test/java/com/bobrust/generator/TwoOptOptimizerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package com.bobrust.generator;

import com.bobrust.generator.sorter.Blob;
import com.bobrust.generator.sorter.BlobList;
import com.bobrust.generator.sorter.BorstSorter;
import com.bobrust.generator.sorter.TwoOptOptimizer;
import com.bobrust.util.data.AppConstants;
import org.junit.jupiter.api.Test;

import java.util.Random;

import static org.junit.jupiter.api.Assertions.*;

/**
* Tests for Proposal 5: Paint Order Optimization with 2-opt TSP heuristic.
*
* Verifies that 2-opt reduces total cost vs. greedy-only ordering,
* preserves the same set of shapes (no shapes lost or duplicated),
* and runs in acceptable time.
*/
class TwoOptOptimizerTest {
private static final int CANVAS_SIZE = 512;

// ---- Test 1: 2-opt reduces or maintains total cost vs greedy ----

@Test
void testTwoOptReducesCost() {
TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE);
Random rnd = new Random(42);

// Generate a set of random blobs
BlobList input = generateRandomBlobs(rnd, 200);

// Sort with greedy (BorstSorter)
BlobList greedy = BorstSorter.sort(input, CANVAS_SIZE);

// Extract greedy order, compute cost before 2-opt
Blob[] greedyArray = greedy.getList().toArray(new Blob[0]);
double greedyCost = optimizer.totalCost(greedyArray);

// Apply 2-opt
Blob[] optimized = greedyArray.clone();
optimizer.optimize(optimized);
double optimizedCost = optimizer.totalCost(optimized);
Comment on lines +34 to +44
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test intends to compare greedy-only ordering vs 2-opt, but it uses BorstSorter.sort(...) as the "greedy" baseline. Since this PR integrates 2-opt into BorstSorter.sort() when USE_TSP_OPTIMIZATION is true, the baseline is already optimized, so the assertion no longer validates the intended behavior. To keep this meaningful, add a greedy-only sorter entry point (or a way to disable optimization for the call) and use that for the baseline here.

Copilot uses AI. Check for mistakes.

System.out.println("Greedy cost: " + String.format("%.4f", greedyCost));
System.out.println("2-opt cost: " + String.format("%.4f", optimizedCost));
System.out.println("Improvement: " + String.format("%.2f%%",
(greedyCost - optimizedCost) / greedyCost * 100));

// 2-opt should not increase cost
assertTrue(optimizedCost <= greedyCost + 1e-6,
"2-opt should not increase cost: greedy=" + greedyCost + " optimized=" + optimizedCost);
}

// ---- Test 2: All shapes are preserved (no duplicates, no losses) ----

@Test
void testAllShapesPreserved() {
TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE);
Random rnd = new Random(123);

BlobList input = generateRandomBlobs(rnd, 100);
BlobList greedy = BorstSorter.sort(input, CANVAS_SIZE);

Blob[] before = greedy.getList().toArray(new Blob[0]);
Blob[] after = before.clone();
optimizer.optimize(after);

assertEquals(before.length, after.length, "Same number of blobs");

// Count occurrences by hashCode
java.util.Map<Integer, Integer> beforeCounts = new java.util.HashMap<>();
java.util.Map<Integer, Integer> afterCounts = new java.util.HashMap<>();
for (Blob b : before) beforeCounts.merge(b.hashCode(), 1, Integer::sum);
for (Blob b : after) afterCounts.merge(b.hashCode(), 1, Integer::sum);

assertEquals(beforeCounts, afterCounts, "Same blobs before and after 2-opt");
Comment on lines +72 to +78
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "all shapes preserved" check uses hashCode() counts as a proxy for identity. Since Blob does not override equals(), and hash codes can theoretically collide, this can produce false positives/negatives. Because optimization should only permute the existing object references, prefer an identity-based check (e.g., IdentityHashMap counts or comparing sorted lists of System.identityHashCode(...)).

Suggested change
// Count occurrences by hashCode
java.util.Map<Integer, Integer> beforeCounts = new java.util.HashMap<>();
java.util.Map<Integer, Integer> afterCounts = new java.util.HashMap<>();
for (Blob b : before) beforeCounts.merge(b.hashCode(), 1, Integer::sum);
for (Blob b : after) afterCounts.merge(b.hashCode(), 1, Integer::sum);
assertEquals(beforeCounts, afterCounts, "Same blobs before and after 2-opt");
// Count occurrences by object identity to verify the optimizer only permutes references
java.util.Map<Blob, Integer> beforeCounts = new java.util.IdentityHashMap<>();
java.util.Map<Blob, Integer> afterCounts = new java.util.IdentityHashMap<>();
for (Blob b : before) beforeCounts.merge(b, 1, Integer::sum);
for (Blob b : after) afterCounts.merge(b, 1, Integer::sum);
assertEquals(beforeCounts, afterCounts, "Same blob references before and after 2-opt");

Copilot uses AI. Check for mistakes.
}

// ---- Test 3: Cost function correctness ----

@Test
void testCostFunction() {
TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE);

// Two identical blobs at the same position: cost should be 0
Blob a = Blob.of(100, 100, 12, BorstUtils.COLORS[5].rgb, 128, AppConstants.CIRCLE_SHAPE);
Blob same = Blob.of(100, 100, 12, BorstUtils.COLORS[5].rgb, 128, AppConstants.CIRCLE_SHAPE);
double costSame = optimizer.cost(a, same);
assertEquals(0.0, costSame, 1e-6, "Same blob should have zero cost");

// Different color only
Blob diffColor = Blob.of(100, 100, 12, BorstUtils.COLORS[10].rgb, 128, AppConstants.CIRCLE_SHAPE);
double costDiffColor = optimizer.cost(a, diffColor);
assertTrue(costDiffColor > 0, "Different color should have positive cost");

// Different position only (same attributes)
Blob diffPos = Blob.of(400, 400, 12, BorstUtils.COLORS[5].rgb, 128, AppConstants.CIRCLE_SHAPE);
double costDiffPos = optimizer.cost(a, diffPos);
assertTrue(costDiffPos > 0, "Different position should have positive cost");

// Different everything: should have higher cost than just one difference
Blob diffAll = Blob.of(400, 400, 50, BorstUtils.COLORS[10].rgb, 255, AppConstants.SQUARE_SHAPE);
double costDiffAll = optimizer.cost(a, diffAll);
assertTrue(costDiffAll > costDiffColor, "All-different should cost more than color-only");
assertTrue(costDiffAll > costDiffPos, "All-different should cost more than position-only");
}

// ---- Test 4: Palette change counting ----

@Test
void testPaletteChangeCount() {
Blob a = Blob.of(50, 50, 12, BorstUtils.COLORS[0].rgb, 128, AppConstants.CIRCLE_SHAPE);

// Same everything
Blob same = Blob.of(50, 50, 12, BorstUtils.COLORS[0].rgb, 128, AppConstants.CIRCLE_SHAPE);
assertEquals(0, TwoOptOptimizer.countPaletteChanges(a, same));

// Different size
Blob diffSize = Blob.of(50, 50, 50, BorstUtils.COLORS[0].rgb, 128, AppConstants.CIRCLE_SHAPE);
assertEquals(1, TwoOptOptimizer.countPaletteChanges(a, diffSize));

// Different color + size
Blob diffTwo = Blob.of(50, 50, 50, BorstUtils.COLORS[5].rgb, 128, AppConstants.CIRCLE_SHAPE);
assertEquals(2, TwoOptOptimizer.countPaletteChanges(a, diffTwo));

// All different
Blob diffAll = Blob.of(50, 50, 50, BorstUtils.COLORS[5].rgb, 255, AppConstants.SQUARE_SHAPE);
assertEquals(4, TwoOptOptimizer.countPaletteChanges(a, diffAll));
}

// ---- Test 5: Edge cases ----

@Test
void testEdgeCases() {
TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE);

// Empty list
BlobList empty = new BlobList();
Blob[] emptyArr = new Blob[0];
assertEquals(0.0, optimizer.totalCost(emptyArr));

// Single blob
Blob[] single = { Blob.of(50, 50, 12, 0, 128, AppConstants.CIRCLE_SHAPE) };
assertEquals(0.0, optimizer.totalCost(single));
optimizer.optimize(single); // should not crash

// Two blobs
Blob[] two = {
Blob.of(50, 50, 12, 0, 128, AppConstants.CIRCLE_SHAPE),
Blob.of(200, 200, 50, BorstUtils.COLORS[10].rgb, 255, AppConstants.CIRCLE_SHAPE)
};
double cost2 = optimizer.totalCost(two);
assertTrue(cost2 > 0);
optimizer.optimize(two); // should not crash
}

// ---- Test 6: Performance — optimization runs within time budget ----

@Test
void testOptimizationPerformance() {
TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE);
Random rnd = new Random(999);

// Test with a moderate number of blobs
BlobList input = generateRandomBlobs(rnd, 500);
BlobList greedy = BorstSorter.sort(input, CANVAS_SIZE);
Blob[] blobs = greedy.getList().toArray(new Blob[0]);

long start = System.nanoTime();
optimizer.optimize(blobs);
long elapsed = System.nanoTime() - start;
Comment on lines +166 to +173
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The performance benchmark times optimizer.optimize(blobs), but the blobs are obtained via BorstSorter.sort(...), which now applies 2-opt already. This means you're measuring a second optimization pass on an already locally-optimized route and may substantially underreport runtime. For a representative benchmark, build the input route without 2-opt (greedy-only) before timing.

Copilot uses AI. Check for mistakes.
double elapsedMs = elapsed / 1_000_000.0;

System.out.println("2-opt on 500 blobs: " + String.format("%.2f", elapsedMs) + " ms");

// Should complete within 5 seconds (generous bound for CI environments)
assertTrue(elapsedMs < 5000, "2-opt should complete within 5s, took " + elapsedMs + "ms");
}

// ---- Test 7: Integration with BorstSorter (end-to-end) ----

@Test
void testIntegrationWithBorstSorter() {
Random rnd = new Random(777);
BlobList input = generateRandomBlobs(rnd, 100);

// BorstSorter.sort should now include 2-opt when USE_TSP_OPTIMIZATION is true
BlobList sorted = BorstSorter.sort(input, CANVAS_SIZE);

// Basic sanity: same number of blobs
assertEquals(input.size(), sorted.size(), "Sorted list should have same size as input");

// Compute cost
TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE);
Blob[] sortedArr = sorted.getList().toArray(new Blob[0]);
double cost = optimizer.totalCost(sortedArr);
System.out.println("End-to-end sorted cost: " + String.format("%.4f", cost));

// Cost should be finite and non-negative
assertTrue(cost >= 0 && Double.isFinite(cost));
}

// ---- Helper: generate random blobs ----

private static BlobList generateRandomBlobs(Random rnd, int count) {
BlobList list = new BlobList();
for (int i = 0; i < count; i++) {
int x = rnd.nextInt(CANVAS_SIZE);
int y = rnd.nextInt(CANVAS_SIZE);
int sizeIdx = rnd.nextInt(BorstUtils.SIZES.length);
int colorIdx = rnd.nextInt(BorstUtils.COLORS.length);
int alphaIdx = rnd.nextInt(BorstUtils.ALPHAS.length);
int shape = rnd.nextBoolean() ? AppConstants.CIRCLE_SHAPE : AppConstants.SQUARE_SHAPE;
list.add(Blob.of(x, y, BorstUtils.SIZES[sizeIdx],
BorstUtils.COLORS[colorIdx].rgb, BorstUtils.ALPHAS[alphaIdx], shape));
}
return list;
}
}