diff --git a/lib/test/hamcrest-core-1.3.jar b/lib/test/hamcrest-core-1.3.jar new file mode 100644 index 0000000..9d5fe16 Binary files /dev/null and b/lib/test/hamcrest-core-1.3.jar differ diff --git a/lib/test/junit-4.13.2.jar b/lib/test/junit-4.13.2.jar new file mode 100644 index 0000000..6da55d8 Binary files /dev/null and b/lib/test/junit-4.13.2.jar differ diff --git a/release/build-common.xml b/release/build-common.xml index 48ad54c..294f48f 100644 --- a/release/build-common.xml +++ b/release/build-common.xml @@ -36,6 +36,9 @@ + + + @@ -81,6 +84,31 @@ + + + + + + + + + + + + + + + + + + + + + + + = rrx1 || y >= rry1) { -// return false; -// } -// double aw = Math.min(getWidth(), Math.abs(getArcWidth())) / 2.0; -// double ah = Math.min(getHeight(), Math.abs(getArcHeight())) / 2.0; -// // Check which corner point is in and do circular containment -// // test - otherwise simple acceptance -// if (x >= (rrx0 += aw) && x < (rrx0 = rrx1 - aw)) { -// return true; -// } -// if (y >= (rry0 += ah) && y < (rry0 = rry1 - ah)) { -// return true; -// } -// x = (x - rrx0) / aw; -// y = (y - rry0) / ah; -// return (x * x + y * y <= 1.0); - - // TBD - return false; + if (isEmpty()) { + return false; + } + + double x0 = this.x; + double y0 = this.y; + double x1 = x0 + width; + double y1 = y0 + height; + + // Check for trivial rejection - point is outside bounding rectangle + if (x < x0 || y < y0 || x >= x1 || y >= y1) { + return false; + } + + // Check each corner. If the point is in a corner's arc zone, test against that corner's ellipse. + // Arc widths and heights are clamped so they don't exceed the rectangle dimensions. + + // Top-left corner + double aw = Math.min(width, Math.abs(tlaw)) / 2.0; + double ah = Math.min(height, Math.abs(tlah)) / 2.0; + if (aw > 0 && ah > 0 && x < x0 + aw && y < y0 + ah) { + double nx = (x - (x0 + aw)) / aw; + double ny = (y - (y0 + ah)) / ah; + return nx * nx + ny * ny <= 1.0; + } + + // Top-right corner + aw = Math.min(width, Math.abs(traw)) / 2.0; + ah = Math.min(height, Math.abs(trah)) / 2.0; + if (aw > 0 && ah > 0 && x >= x1 - aw && y < y0 + ah) { + double nx = (x - (x1 - aw)) / aw; + double ny = (y - (y0 + ah)) / ah; + return nx * nx + ny * ny <= 1.0; + } + + // Bottom-right corner + aw = Math.min(width, Math.abs(braw)) / 2.0; + ah = Math.min(height, Math.abs(brah)) / 2.0; + if (aw > 0 && ah > 0 && x >= x1 - aw && y >= y1 - ah) { + double nx = (x - (x1 - aw)) / aw; + double ny = (y - (y1 - ah)) / ah; + return nx * nx + ny * ny <= 1.0; + } + + // Bottom-left corner + aw = Math.min(width, Math.abs(blaw)) / 2.0; + ah = Math.min(height, Math.abs(blah)) / 2.0; + if (aw > 0 && ah > 0 && x < x0 + aw && y >= y1 - ah) { + double nx = (x - (x0 + aw)) / aw; + double ny = (y - (y1 - ah)) / ah; + return nx * nx + ny * ny <= 1.0; + } + + // Not in any corner zone — must be in the body of the rectangle + return true; } -// private int classify(double coord, double left, double right, -// double arcsize) -// { -// if (coord < left) { -// return 0; -// } else if (coord < left + arcsize) { -// return 1; -// } else if (coord < right - arcsize) { -// return 2; -// } else if (coord < right) { -// return 3; -// } else { -// return 4; -// } -// } + /** + Test whether a corner's quarter-ellipse intersects with a rectangle. The corner point is at (cx, cy), the arc + zone extends inward by aw horizontally and ah vertically, and the rectangle to test is given by its edges. + + @return true if the quarter-ellipse region intersects the given rectangle. + */ + private static boolean cornerIntersects(double cx, double cy, double aw, double ah, + double rx0, double ry0, double rx1, double ry1, + double signX, double signY) + { + // Find the point in the rectangle nearest to the ellipse center + double ecx = cx + signX * aw; + double ecy = cy + signY * ah; + double nearestX = Math.max(rx0, Math.min(rx1, ecx)); + double nearestY = Math.max(ry0, Math.min(ry1, ecy)); + double nx = (nearestX - ecx) / aw; + double ny = (nearestY - ecy) / ah; + return nx * nx + ny * ny <= 1.0; + } public boolean intersects(double x, double y, double w, double h) { -// if (isEmpty() || w <= 0 || h <= 0) { -// return false; -// } -// double rrx0 = getX(); -// double rry0 = getY(); -// double rrx1 = rrx0 + getWidth(); -// double rry1 = rry0 + getHeight(); -// // Check for trivial rejection - bounding rectangles do not intersect -// if (x + w <= rrx0 || x >= rrx1 || y + h <= rry0 || y >= rry1) { -// return false; -// } -// double aw = Math.min(getWidth(), Math.abs(getArcWidth())) / 2.0; -// double ah = Math.min(getHeight(), Math.abs(getArcHeight())) / 2.0; -// int x0class = classify(x, rrx0, rrx1, aw); -// int x1class = classify(x + w, rrx0, rrx1, aw); -// int y0class = classify(y, rry0, rry1, ah); -// int y1class = classify(y + h, rry0, rry1, ah); -// // Trivially accept if any point is inside inner rectangle -// if (x0class == 2 || x1class == 2 || y0class == 2 || y1class == 2) { -// return true; -// } -// // Trivially accept if either edge spans inner rectangle -// if ((x0class < 2 && x1class > 2) || (y0class < 2 && y1class > 2)) { -// return true; -// } -// // Since neither edge spans the center, then one of the corners -// // must be in one of the rounded edges. We detect this case if -// // a [xy]0class is 3 or a [xy]1class is 1. One of those two cases -// // must be true for each direction. -// // We now find a "nearest point" to test for being inside a rounded -// // corner. -// x = (x1class == 1) ? (x = x + w - (rrx0 + aw)) : (x = x - (rrx1 - aw)); -// y = (y1class == 1) ? (y = y + h - (rry0 + ah)) : (y = y - (rry1 - ah)); -// x = x / aw; -// y = y / ah; -// return (x * x + y * y <= 1.0); - - // TBD - return false; + if (isEmpty() || w <= 0 || h <= 0) { + return false; + } + + double x0 = this.x; + double y0 = this.y; + double x1 = x0 + width; + double y1 = y0 + height; + + // Check for trivial rejection - bounding rectangles do not intersect + if (x + w <= x0 || x >= x1 || y + h <= y0 || y >= y1) { + return false; + } + + // Clamp arc radii + double tlawH = Math.min(width, Math.abs(tlaw)) / 2.0; + double tlahH = Math.min(height, Math.abs(tlah)) / 2.0; + double trawH = Math.min(width, Math.abs(traw)) / 2.0; + double trahH = Math.min(height, Math.abs(trah)) / 2.0; + double brawH = Math.min(width, Math.abs(braw)) / 2.0; + double brahH = Math.min(height, Math.abs(brah)) / 2.0; + double blawH = Math.min(width, Math.abs(blaw)) / 2.0; + double blahH = Math.min(height, Math.abs(blah)) / 2.0; + + // Fast path: if the query rect extends into the horizontal or vertical middle band, + // it definitely intersects. The middle band is the region clear of all corner arcs. + double leftMax = Math.max(tlawH, blawH); + double rightMax = Math.max(trawH, brawH); + double topMax = Math.max(tlahH, trahH); + double bottomMax = Math.max(blahH, brahH); + + if (x + w > x0 + leftMax && x < x1 - rightMax) { + return true; + } + if (y + h > y0 + topMax && y < y1 - bottomMax) { + return true; + } + + // Clip query rect to the bounding rect for corner zone checks. + double cx0 = Math.max(x, x0); + double cy0 = Math.max(y, y0); + double cx1 = Math.min(x + w, x1); + double cy1 = Math.min(y + h, y1); + + // Check each corner. If the clipped rect overlaps a corner zone with a non-zero arc, + // test whether the rect reaches the elliptical arc. Sharp corners (arc=0) have a + // zero-size zone, so they are never entered — this is correct because a sharp corner + // cuts no area from the bounding rect. + boolean anyMiss = false; + + if (cx0 < x0 + tlawH && cy0 < y0 + tlahH && tlawH > 0 && tlahH > 0) { + if (cornerIntersects(x0, y0, tlawH, tlahH, cx0, cy0, cx1, cy1, 1, 1)) { + return true; + } + anyMiss = true; + } + + if (cx1 > x1 - trawH && cy0 < y0 + trahH && trawH > 0 && trahH > 0) { + if (cornerIntersects(x1, y0, trawH, trahH, cx0, cy0, cx1, cy1, -1, 1)) { + return true; + } + anyMiss = true; + } + + if (cx1 > x1 - brawH && cy1 > y1 - brahH && brawH > 0 && brahH > 0) { + if (cornerIntersects(x1, y1, brawH, brahH, cx0, cy0, cx1, cy1, -1, -1)) { + return true; + } + anyMiss = true; + } + + if (cx0 < x0 + blawH && cy1 > y1 - blahH && blawH > 0 && blahH > 0) { + if (cornerIntersects(x0, y1, blawH, blahH, cx0, cy0, cx1, cy1, 1, -1)) { + return true; + } + anyMiss = true; + } + + // If no corner zones were overlapped, the clipped rect is entirely in the body. + if (!anyMiss) { + return true; + } + + // The clipped rect missed one or more corner ellipses. It may still intersect if + // part of the rect extends beyond those corner zones into the body. Check whether + // the center of the clipped rect is inside the shape. + double midX = (cx0 + cx1) / 2; + double midY = (cy0 + cy1) / 2; + + if (tlawH > 0 && tlahH > 0 && midX < x0 + tlawH && midY < y0 + tlahH) { + double nx = (midX - (x0 + tlawH)) / tlawH; + double ny = (midY - (y0 + tlahH)) / tlahH; + return nx * nx + ny * ny <= 1.0; + } + if (trawH > 0 && trahH > 0 && midX > x1 - trawH && midY < y0 + trahH) { + double nx = (midX - (x1 - trawH)) / trawH; + double ny = (midY - (y0 + trahH)) / trahH; + return nx * nx + ny * ny <= 1.0; + } + if (brawH > 0 && brahH > 0 && midX > x1 - brawH && midY > y1 - brahH) { + double nx = (midX - (x1 - brawH)) / brawH; + double ny = (midY - (y1 - brahH)) / brahH; + return nx * nx + ny * ny <= 1.0; + } + if (blawH > 0 && blahH > 0 && midX < x0 + blawH && midY > y1 - blahH) { + double nx = (midX - (x0 + blawH)) / blawH; + double ny = (midY - (y1 - blahH)) / blahH; + return nx * nx + ny * ny <= 1.0; + } + + // Center is not in any corner zone — it's in the body. + return true; } public boolean contains(double x, double y, double w, double h) { diff --git a/test/org/violetlib/geom/GeneralRoundRectangleTest.java b/test/org/violetlib/geom/GeneralRoundRectangleTest.java new file mode 100644 index 0000000..49495e5 --- /dev/null +++ b/test/org/violetlib/geom/GeneralRoundRectangleTest.java @@ -0,0 +1,324 @@ +package org.violetlib.geom; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class GeneralRoundRectangleTest +{ + // A 100x100 rectangle at (0,0) with uniform arc width/height of 20 + private static GeneralRoundRectangle uniform() + { + return new GeneralRoundRectangle(0, 0, 100, 100, + 20, 20, 20, 20, 20, 20, 20, 20); + } + + // A 100x100 rectangle at (0,0) with no rounding + private static GeneralRoundRectangle sharp() + { + return new GeneralRoundRectangle(0, 0, 100, 100, + 0, 0, 0, 0, 0, 0, 0, 0); + } + + // A 100x80 rectangle at (10,10) with different arcs per corner + private static GeneralRoundRectangle asymmetric() + { + return new GeneralRoundRectangle(10, 10, 100, 80, + 30, 20, // tlaw, tlah (top-left) + 10, 40, // traw, trah (top-right) + 20, 10, // braw, brah (bottom-right) + 40, 30); // blaw, blah (bottom-left) + } + + // ---------- contains(x, y) ---------- + + @Test + public void contains_emptyRect_returnsFalse() + { + GeneralRoundRectangle rr = new GeneralRoundRectangle(0, 0, 0, 100, + 10, 10, 10, 10, 10, 10, 10, 10); + assertFalse(rr.contains(0, 50)); + } + + @Test + public void contains_outsideBounds_returnsFalse() + { + GeneralRoundRectangle rr = uniform(); + assertFalse(rr.contains(-1, 50)); + assertFalse(rr.contains(50, -1)); + assertFalse(rr.contains(100, 50)); + assertFalse(rr.contains(50, 100)); + } + + @Test + public void contains_center_returnsTrue() + { + assertTrue(uniform().contains(50, 50)); + } + + @Test + public void contains_midEdge_returnsTrue() + { + GeneralRoundRectangle rr = uniform(); + // Middle of each edge — well inside the body, outside any corner zone + assertTrue(rr.contains(50, 0)); // top edge center + assertTrue(rr.contains(50, 99)); // bottom edge center + assertTrue(rr.contains(0, 50)); // left edge center + assertTrue(rr.contains(99, 50)); // right edge center + } + + @Test + public void contains_cornerOutsideArc_returnsFalse() + { + GeneralRoundRectangle rr = uniform(); + // Very close to the corner, outside the elliptical arc + // Arc radius is 10 (arcWidth=20, half=10). Point (0,0) is the corner. + // At (1, 1): nx = (1-10)/10 = -0.9, ny = (1-10)/10 = -0.9 + // nx^2 + ny^2 = 0.81 + 0.81 = 1.62 > 1 → outside + assertFalse(rr.contains(1, 1)); + assertFalse(rr.contains(99, 1)); + assertFalse(rr.contains(99, 99)); + assertFalse(rr.contains(1, 99)); + } + + @Test + public void contains_cornerInsideArc_returnsTrue() + { + GeneralRoundRectangle rr = uniform(); + // Arc radius is 10. Point (5, 5): + // nx = (5-10)/10 = -0.5, ny = (5-10)/10 = -0.5 + // nx^2 + ny^2 = 0.25 + 0.25 = 0.5 <= 1 → inside + assertTrue(rr.contains(5, 5)); + assertTrue(rr.contains(95, 5)); + assertTrue(rr.contains(95, 95)); + assertTrue(rr.contains(5, 95)); + } + + @Test + public void contains_sharpCorners_returnsTrue() + { + GeneralRoundRectangle rr = sharp(); + // With no rounding, all corners should be contained + assertTrue(rr.contains(0, 0)); + assertTrue(rr.contains(99, 0)); + assertTrue(rr.contains(99, 99)); + assertTrue(rr.contains(0, 99)); + } + + @Test + public void contains_onArcBoundary_returnsTrue() + { + GeneralRoundRectangle rr = uniform(); + // Point exactly on the ellipse boundary (nx^2 + ny^2 == 1.0) + // At the arc center entry point: (10, 0) + // nx = (10-10)/10 = 0, ny = (0-10)/10 = -1 + // nx^2 + ny^2 = 0 + 1 = 1.0 → on boundary, should be contained (<= 1.0) + assertTrue(rr.contains(10, 0)); + } + + @Test + public void contains_asymmetricCorners() + { + GeneralRoundRectangle rr = asymmetric(); + // Center is definitely inside + assertTrue(rr.contains(60, 50)); + + // Top-left corner: arc is 30w, 20h → half = 15, 10 + // Corner zone: x < 10+15=25, y < 10+10=20 + // Point (11, 11): nx = (11-25)/15 = -0.933, ny = (11-20)/10 = -0.9 + // nx^2 + ny^2 = 0.871 + 0.81 = 1.681 > 1 → outside + assertFalse(rr.contains(11, 11)); + + // Point (20, 15): nx = (20-25)/15 = -0.333, ny = (15-20)/10 = -0.5 + // nx^2 + ny^2 = 0.111 + 0.25 = 0.361 → inside + assertTrue(rr.contains(20, 15)); + } + + // ---------- contains(x, y, w, h) ---------- + + @Test + public void containsRect_fullyInside_returnsTrue() + { + assertTrue(uniform().contains(20, 20, 60, 60)); + } + + @Test + public void containsRect_overlapCorner_returnsFalse() + { + // Small rect in the very corner — all 4 corner points are outside the arc + assertFalse(uniform().contains(0, 0, 2, 2)); + } + + @Test + public void containsRect_empty_returnsFalse() + { + assertFalse(uniform().contains(50, 50, 0, 10)); + assertFalse(uniform().contains(50, 50, 10, 0)); + } + + // ---------- intersects(x, y, w, h) ---------- + + @Test + public void intersects_emptyShape_returnsFalse() + { + GeneralRoundRectangle rr = new GeneralRoundRectangle(0, 0, 0, 100, + 10, 10, 10, 10, 10, 10, 10, 10); + assertFalse(rr.intersects(0, 0, 50, 50)); + } + + @Test + public void intersects_emptyQuery_returnsFalse() + { + assertFalse(uniform().intersects(50, 50, 0, 10)); + assertFalse(uniform().intersects(50, 50, 10, 0)); + } + + @Test + public void intersects_noOverlap_returnsFalse() + { + GeneralRoundRectangle rr = uniform(); + assertFalse(rr.intersects(200, 200, 10, 10)); + assertFalse(rr.intersects(-20, 50, 10, 10)); + assertFalse(rr.intersects(110, 50, 10, 10)); + } + + @Test + public void intersects_middleBand_returnsTrue() + { + GeneralRoundRectangle rr = uniform(); + // Rect that overlaps the horizontal middle band (extends from above into the top edge) + assertTrue(rr.intersects(40, -10, 20, 15)); + // Rect that overlaps the vertical middle band (extends from left into the left edge) + assertTrue(rr.intersects(-10, 40, 15, 20)); + } + + @Test + public void intersects_fullyInside_returnsTrue() + { + assertTrue(uniform().intersects(20, 20, 60, 60)); + } + + @Test + public void intersects_cornerOverlap_returnsTrue() + { + GeneralRoundRectangle rr = uniform(); + // A rect entirely within the top-left corner zone that touches the arc. + // Arc radius = 10. Rect (2,2)-(9,9). Nearest point to ellipse center (10,10) is (9,9). + // nx = (9-10)/10 = -0.1, ny = (9-10)/10 = -0.1. nx^2+ny^2 = 0.02 <= 1 → hit + assertTrue(rr.intersects(2, 2, 7, 7)); + } + + @Test + public void intersects_cornerMiss_returnsFalse() + { + GeneralRoundRectangle rr = uniform(); + // A tiny rect in the very corner that doesn't touch the arc. + // Arc center is at (10, 10). Rect (0,0)-(1,1). + // Nearest point to center (10,10) is (1,1). + // nx = (1-10)/10 = -0.9, ny = (1-10)/10 = -0.9 + // nx^2 + ny^2 = 1.62 > 1 → miss + assertFalse(rr.intersects(0, 0, 1, 1)); + } + + @Test + public void intersects_allCornerMisses() + { + GeneralRoundRectangle rr = uniform(); + assertFalse(rr.intersects(0, 0, 1, 1)); // top-left + assertFalse(rr.intersects(99, 0, 1, 1)); // top-right + assertFalse(rr.intersects(99, 99, 1, 1)); // bottom-right + assertFalse(rr.intersects(0, 99, 1, 1)); // bottom-left + } + + @Test + public void intersects_sharpCorners_returnsTrue() + { + GeneralRoundRectangle rr = sharp(); + // With no rounding, any overlap with the bounding rect should intersect + assertTrue(rr.intersects(0, 0, 1, 1)); + assertTrue(rr.intersects(99, 99, 1, 1)); + } + + @Test + public void intersects_spanningEntireShape_returnsTrue() + { + assertTrue(uniform().intersects(-10, -10, 120, 120)); + } + + @Test + public void intersects_touchingEdge_returnsTrue() + { + // Rect that just touches the top edge in the middle (no corner involvement) + assertTrue(uniform().intersects(40, -5, 20, 6)); + } + + @Test + public void intersects_justOutside_returnsFalse() + { + // Rect that is just outside the right edge + assertFalse(uniform().intersects(100, 40, 10, 20)); + } + + @Test + public void intersects_sharpCornerWithOtherCornersRounded() + { + // Top-left is sharp (arc=0), all other corners are rounded (arc=20). + GeneralRoundRectangle rr = new GeneralRoundRectangle(0, 0, 100, 100, + 0, 0, 20, 20, 20, 20, 20, 20); + + // A rect at the sharp top-left corner should intersect since there is no arc to miss. + assertTrue(rr.intersects(0, 0, 1, 1)); + + // A rect extending from outside into the sharp corner (tests clipping + !anyMiss path) + assertTrue(rr.intersects(-5, -5, 7, 7)); + + // A rect at each rounded corner should still miss the arc + assertFalse(rr.intersects(99, 0, 1, 1)); // top-right + assertFalse(rr.intersects(99, 99, 1, 1)); // bottom-right + assertFalse(rr.intersects(0, 99, 1, 1)); // bottom-left + } + + @Test + public void intersects_overlappingArcs_centerInBody() + { + // Very large arcs that nearly meet in the middle. The arc zones from adjacent corners + // leave only a small body in the center. A rect spanning two adjacent corner zones + // should intersect because its center falls in the body. + GeneralRoundRectangle rr = new GeneralRoundRectangle(0, 0, 100, 100, + 90, 90, 90, 90, 90, 90, 90, 90); + // Arc half = 45. Middle band: x > 45 and x < 55 (only 10 wide). + // A rect from (0,48) to (100,52): horizontal band check 100>45 && 0<55 → caught by fast path. + // A rect confined to the top-left quadrant but extending past the zone: + // (0, 0, 50, 50) — horizontal band: 50>45 && 0<55 → caught by fast path. + // For center-in-body path, need to bypass fast path: + // Rect (40, 40, 20, 20) — horizontal: 60>45 && 40<55 → caught. + // It's very hard to reach the center-in-body path without the fast path catching it. + // This test verifies the shape works correctly with large arcs. + assertTrue(rr.intersects(40, 40, 20, 20)); + // Corner miss with large arcs + assertFalse(rr.intersects(0, 0, 1, 1)); + } + + @Test + public void intersects_asymmetricCorners() + { + GeneralRoundRectangle rr = asymmetric(); + + // Center overlap + assertTrue(rr.intersects(50, 40, 20, 20)); + + // Top-left corner miss: arc is 30w, 20h → half = 15, 10 + // Corner at (10, 10). Tiny rect at (10, 10) size 1x1. + // Nearest point to ellipse center (25, 20) is (11, 11). + // nx = (11-25)/15 = -0.933, ny = (11-20)/10 = -0.9 + // nx^2 + ny^2 = 0.871 + 0.81 = 1.681 > 1 → miss + assertFalse(rr.intersects(10, 10, 1, 1)); + + // Bottom-right corner: arc is 20w, 10h → half = 10, 5 + // Corner at (110, 90). Tiny rect at (109, 89) size 1x1. + // Nearest point to ellipse center (100, 85) is (109, 85). + // nx = (109-100)/10 = 0.9, ny = 0 + // nx^2 + ny^2 = 0.81 → inside, so intersects + assertTrue(rr.intersects(109, 85, 1, 1)); + } +}