Skip to content

Commit 610c7c6

Browse files
committed
feat: add wu's line drawing algorithm
1 parent 7e29be3 commit 610c7c6

File tree

2 files changed

+333
-0
lines changed

2 files changed

+333
-0
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
package com.thealgorithms.geometry;
2+
3+
import java.awt.Point;
4+
import java.util.ArrayList;
5+
import java.util.List;
6+
7+
/**
8+
* The {@code WusLine} class implements Xiaolin Wu's line drawing algorithm,
9+
* which produces anti-aliased lines by varying pixel brightness
10+
* according to the line's proximity to pixel centers.
11+
*
12+
* This implementation returns the pixel coordinates along with
13+
* their associated intensity values (in range [0.0, 1.0]), allowing
14+
* rendering systems to blend accordingly.
15+
*
16+
* The algorithm works by:
17+
* - Computing a line's intersection with pixel boundaries
18+
* - Assigning intensity values based on distance from pixel centers
19+
* - Drawing pairs of pixels perpendicular to the line's direction
20+
*
21+
* Reference: Xiaolin Wu, "An Efficient Antialiasing Technique",
22+
* Computer Graphics (SIGGRAPH '91 Proceedings).
23+
*
24+
*/
25+
public final class WusLine {
26+
27+
private WusLine() {
28+
// Utility class; prevent instantiation.
29+
}
30+
31+
/**
32+
* Represents a pixel and its intensity for anti-aliased rendering.
33+
*
34+
* The intensity value determines how bright the pixel should be drawn,
35+
* with 1.0 being fully opaque and 0.0 being fully transparent.
36+
*/
37+
public static class Pixel {
38+
/** The pixel's coordinate on the screen. */
39+
public final Point point;
40+
41+
/** The pixel's intensity value, clamped to the range [0.0, 1.0]. */
42+
public final double intensity;
43+
44+
/**
45+
* Constructs a new Pixel with the given coordinates and intensity.
46+
*
47+
* @param x the x-coordinate of the pixel
48+
* @param y the y-coordinate of the pixel
49+
* @param intensity the brightness/opacity of the pixel, will be clamped to [0.0, 1.0]
50+
*/
51+
public Pixel(int x, int y, double intensity) {
52+
this.point = new Point(x, y);
53+
this.intensity = Math.clamp(intensity, 0.0, 1.0);
54+
}
55+
}
56+
57+
/**
58+
* Draws an anti-aliased line using Wu's algorithm.
59+
*
60+
* The algorithm produces smooth lines by drawing pairs of pixels at each
61+
* x-coordinate (or y-coordinate for steep lines), with intensities based on
62+
* the line's distance from pixel centers.
63+
*
64+
* Example usage:
65+
* {@code
66+
* List<Pixel> linePixels = WusLine.drawLine(10, 20, 100, 80);
67+
* for (Pixel p : linePixels) {
68+
* drawPixel(p.point.x, p.point.y, p.intensity);
69+
* }
70+
* }
71+
*
72+
* @param x0 the x-coordinate of the line's start point
73+
* @param y0 the y-coordinate of the line's start point
74+
* @param x1 the x-coordinate of the line's end point
75+
* @param y1 the y-coordinate of the line's end point
76+
* @return a list of {@link Pixel} objects representing the anti-aliased line,
77+
* ordered from start to end
78+
*/
79+
public static List<Pixel> drawLine(int x0, int y0, int x1, int y1) {
80+
List<Pixel> pixels = new ArrayList<>();
81+
82+
// Determine if the line is steep (more vertical than horizontal)
83+
boolean steep = Math.abs(y1 - y0) > Math.abs(x1 - x0);
84+
85+
if (steep) {
86+
// For steep lines, swap x and y coordinates to iterate along y-axis
87+
int temp = x0;
88+
x0 = y0;
89+
y0 = temp;
90+
91+
temp = x1;
92+
x1 = y1;
93+
y1 = temp;
94+
}
95+
96+
if (x0 > x1) {
97+
// Ensure we always draw from left to right
98+
int temp = x0;
99+
x0 = x1;
100+
x1 = temp;
101+
102+
temp = y0;
103+
y0 = y1;
104+
y1 = temp;
105+
}
106+
107+
// Calculate the line's slope
108+
double deltaX = x1 - (double) x0;
109+
double deltaY = y1 - (double) y0;
110+
double gradient = (deltaX == 0) ? 1.0 : deltaY / deltaX;
111+
112+
// Process the first endpoint
113+
EndpointData firstEndpoint = processEndpoint(x0, y0, gradient, true);
114+
addEndpointPixels(pixels, firstEndpoint, steep);
115+
116+
// Process the second endpoint
117+
EndpointData secondEndpoint = processEndpoint(x1, y1, gradient, false);
118+
addEndpointPixels(pixels, secondEndpoint, steep);
119+
120+
// Draw the main line between endpoints
121+
drawMainLine(pixels, firstEndpoint, secondEndpoint, gradient, steep);
122+
123+
return pixels;
124+
}
125+
126+
/**
127+
* Processes a line endpoint to determine its pixel coordinates and intensities.
128+
*
129+
* @param x the x-coordinate of the endpoint
130+
* @param y the y-coordinate of the endpoint
131+
* @param gradient the slope of the line
132+
* @param isStart true if this is the start endpoint, false if it's the end
133+
* @return an {@link EndpointData} object containing processed endpoint information
134+
*/
135+
private static EndpointData processEndpoint(double x, double y, double gradient, boolean isStart) {
136+
double xEnd = round(x);
137+
double yEnd = y + gradient * (xEnd - x);
138+
double xGap = isStart ? rfpart(x + 0.5) : fpart(x + 0.5);
139+
140+
int xPixel = (int) xEnd;
141+
int yPixel = (int) Math.floor(yEnd);
142+
143+
return new EndpointData(xPixel, yPixel, yEnd, xGap);
144+
}
145+
146+
/**
147+
* Adds the two endpoint pixels (one above, one below the line) to the pixel list.
148+
*
149+
* @param pixels the list to add pixels to
150+
* @param endpoint the endpoint data containing coordinates and gaps
151+
* @param steep true if the line is steep (coordinates should be swapped)
152+
*/
153+
private static void addEndpointPixels(List<Pixel> pixels, EndpointData endpoint, boolean steep) {
154+
double fractionalY = fpart(endpoint.yEnd);
155+
double complementFractionalY = rfpart(endpoint.yEnd);
156+
157+
if (steep) {
158+
pixels.add(new Pixel(endpoint.yPixel, endpoint.xPixel, complementFractionalY * endpoint.xGap));
159+
pixels.add(new Pixel(endpoint.yPixel + 1, endpoint.xPixel, fractionalY * endpoint.xGap));
160+
} else {
161+
pixels.add(new Pixel(endpoint.xPixel, endpoint.yPixel, complementFractionalY * endpoint.xGap));
162+
pixels.add(new Pixel(endpoint.xPixel, endpoint.yPixel + 1, fractionalY * endpoint.xGap));
163+
}
164+
}
165+
166+
/**
167+
* Draws the main portion of the line between the two endpoints.
168+
*
169+
* @param pixels the list to add pixels to
170+
* @param firstEndpoint the processed start endpoint
171+
* @param secondEndpoint the processed end endpoint
172+
* @param gradient the slope of the line
173+
* @param steep true if the line is steep (coordinates should be swapped)
174+
*/
175+
private static void drawMainLine(List<Pixel> pixels, EndpointData firstEndpoint, EndpointData secondEndpoint, double gradient, boolean steep) {
176+
// Start y-intersection after the first endpoint
177+
double intersectionY = firstEndpoint.yEnd + gradient;
178+
179+
// Iterate through x-coordinates between the endpoints
180+
for (int x = firstEndpoint.xPixel + 1; x < secondEndpoint.xPixel; x++) {
181+
int yFloor = (int) Math.floor(intersectionY);
182+
double fractionalPart = fpart(intersectionY);
183+
double complementFractionalPart = rfpart(intersectionY);
184+
185+
if (steep) {
186+
pixels.add(new Pixel(yFloor, x, complementFractionalPart));
187+
pixels.add(new Pixel(yFloor + 1, x, fractionalPart));
188+
} else {
189+
pixels.add(new Pixel(x, yFloor, complementFractionalPart));
190+
pixels.add(new Pixel(x, yFloor + 1, fractionalPart));
191+
}
192+
193+
intersectionY += gradient;
194+
}
195+
}
196+
197+
/**
198+
* Returns the fractional part of a number.
199+
*
200+
* @param x the input number
201+
* @return the fractional part (always in range [0.0, 1.0))
202+
*/
203+
private static double fpart(double x) {
204+
return x - Math.floor(x);
205+
}
206+
207+
/**
208+
* Returns the reverse fractional part of a number (1 - fractional part).
209+
*
210+
* @param x the input number
211+
* @return 1.0 minus the fractional part (always in range (0.0, 1.0])
212+
*/
213+
private static double rfpart(double x) {
214+
return 1.0 - fpart(x);
215+
}
216+
217+
/**
218+
* Rounds a number to the nearest integer.
219+
*
220+
* @param x the input number
221+
* @return the nearest integer value as a double
222+
*/
223+
private static double round(double x) {
224+
return Math.floor(x + 0.5);
225+
}
226+
227+
/**
228+
* Internal class to hold processed endpoint data.
229+
*/
230+
private static class EndpointData {
231+
final int xPixel;
232+
final int yPixel;
233+
final double yEnd;
234+
final double xGap;
235+
236+
EndpointData(int xPixel, int yPixel, double yEnd, double xGap) {
237+
this.xPixel = xPixel;
238+
this.yPixel = yPixel;
239+
this.yEnd = yEnd;
240+
this.xGap = xGap;
241+
}
242+
}
243+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.thealgorithms.geometry;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import java.util.List;
8+
import org.junit.jupiter.api.Test;
9+
10+
/**
11+
* Unit tests for the {@link WusLine} class.
12+
*/
13+
class WusLineTest {
14+
15+
@Test
16+
void testSimpleLineProducesPixels() {
17+
List<WusLine.Pixel> pixels = WusLine.drawLine(2, 2, 6, 4);
18+
assertFalse(pixels.isEmpty(), "Line should produce non-empty pixel list");
19+
}
20+
21+
@Test
22+
void testEndpointsIncluded() {
23+
List<WusLine.Pixel> pixels = WusLine.drawLine(0, 0, 5, 3);
24+
boolean hasStart = pixels.stream().anyMatch(p -> p.point.equals(new java.awt.Point(0, 0)));
25+
boolean hasEnd = pixels.stream().anyMatch(p -> p.point.equals(new java.awt.Point(5, 3)));
26+
assertTrue(hasStart, "Start point should be represented in the pixel list");
27+
assertTrue(hasEnd, "End point should be represented in the pixel list");
28+
}
29+
30+
@Test
31+
void testIntensityInRange() {
32+
List<WusLine.Pixel> pixels = WusLine.drawLine(1, 1, 8, 5);
33+
for (WusLine.Pixel pixel : pixels) {
34+
assertTrue(pixel.intensity >= 0.0 && pixel.intensity <= 1.0, "Intensity must be clamped between 0.0 and 1.0");
35+
}
36+
}
37+
38+
@Test
39+
void testReversedEndpointsProducesSameLine() {
40+
List<WusLine.Pixel> forward = WusLine.drawLine(2, 2, 10, 5);
41+
List<WusLine.Pixel> backward = WusLine.drawLine(10, 5, 2, 2);
42+
43+
// They should cover same coordinates (ignoring order)
44+
var forwardPoints = forward.stream().map(p -> p.point).collect(java.util.stream.Collectors.toSet());
45+
var backwardPoints = backward.stream().map(p -> p.point).collect(java.util.stream.Collectors.toSet());
46+
47+
assertEquals(forwardPoints, backwardPoints, "Reversing endpoints should yield same line pixels");
48+
}
49+
50+
@Test
51+
void testSteepLineHasProperCoverage() {
52+
// Steep line: Δy > Δx
53+
List<WusLine.Pixel> pixels = WusLine.drawLine(3, 2, 5, 10);
54+
assertFalse(pixels.isEmpty());
55+
// Expect increasing y values
56+
long increasing = 0;
57+
for (int i = 1; i < pixels.size(); i++) {
58+
if (pixels.get(i).point.y >= pixels.get(i - 1).point.y) {
59+
increasing++;
60+
}
61+
}
62+
assertTrue(increasing > pixels.size() / 2, "Steep line should have increasing y coordinates");
63+
}
64+
65+
@Test
66+
void testZeroLengthLineUsesDefaultGradient() {
67+
// same start and end -> dx == 0 -> gradient should take the (dx == 0) ? 1.0 branch
68+
List<WusLine.Pixel> pixels = WusLine.drawLine(3, 3, 3, 3);
69+
70+
// sanity checks: we produced pixels and the exact point is present
71+
assertFalse(pixels.isEmpty(), "Zero-length line should produce at least one pixel");
72+
assertTrue(pixels.stream().anyMatch(p -> p.point.equals(new java.awt.Point(3, 3))), "Pixel list should include the single-point coordinate (3,3)");
73+
}
74+
75+
@Test
76+
void testHorizontalLineIntensityStable() {
77+
List<WusLine.Pixel> pixels = WusLine.drawLine(1, 5, 8, 5);
78+
79+
// For each x, take the max intensity among pixels with that x (the visible intensity for the column)
80+
java.util.Map<Integer, Double> maxIntensityByX = pixels.stream()
81+
.collect(java.util.stream.Collectors.groupingBy(p -> p.point.x, java.util.stream.Collectors.mapping(p -> p.intensity, java.util.stream.Collectors.maxBy(Double::compareTo))))
82+
.entrySet()
83+
.stream()
84+
.collect(java.util.stream.Collectors.toMap(java.util.Map.Entry::getKey, e -> e.getValue().orElse(0.0)));
85+
86+
double avgMaxIntensity = maxIntensityByX.values().stream().mapToDouble(Double::doubleValue).average().orElse(0.0);
87+
88+
assertTrue(avgMaxIntensity > 0.5, "Average of the maximum per-x intensities should be > 0.5 for a horizontal line");
89+
}
90+
}

0 commit comments

Comments
 (0)