Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,11 @@
* {@link HalfDiscOverlay#containedShell}; not a noder. Anything else
* -- not both discs, 1 intersection, a nest that is not strictly
* inside ({@code H-ANNULUS-TANGENT}: internal tangent, 1 node,
* d+r = R) -- returns {@code null} so the caller can take the chord
* baseline without paying this path first.
* d+r = R), or a nest that is not two certified discs
* ({@code CC-NEST-ANNULUS}: mixed CompoundCurve stadium / half-disc
* in a disc) -- returns {@code null} so the caller can take the chord
* baseline without paying this path first. A CompoundCurve of only
* CircularStrings that sweep 2π certifies as a disc and stays here.
*/
final class CircularDiscOverlay {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
* as a degenerate NSpan), or a two-node walk vs a disc or plain
* polygon via {@link TwoNodeClip}. A hole that straddles the
* other shell, or two holes that cross, stay {@code null} (bite
* / noder, not a kit). A miss is {@code null}.
* / noder, not a kit). A 0-node mixed shell vs a circular disc
* ({@code CC-NEST-ANNULUS}) is not a punch. A miss is {@code null}.
*/
final class CompoundCurveShellOverlay {

Expand Down Expand Up @@ -132,6 +133,7 @@ private static Geometry clip(CurvePolygon shell, Other other,
List<TwoNodeClip.Edge> edges = TwoNodeClip.flatten(shell);
if (edges == null) return null;
List<TwoNodeClip.Node> nodes = other.nodes(edges);
// 0-node mixed-vs-disc is CC-NEST-ANNULUS, not a stadium punch.
if (!TwoNodeClip.properPair(nodes, other.scale())) return null;

TwoNodeClip.Node p = nodes.get(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@
* arcs (lens, blob, crescent) or a {@link MultiSurface} of two crescents.
* Nested discs (0 nodes, one strictly inside the other) are the
* annulus: SUB the outer with the inner as a hole, XOR the same.
* Closed form; no densification. 1 intersection, a tangent nest, or
* Closed form; no densification. 1 intersection, a tangent nest,
* a mixed CompoundCurve nest ({@code CC-NEST-ANNULUS}), or
* a non-disc, falls through without paying this path.</li>
* <li><b>R1.6</b> -- one operand is a circular disc and the other is a
* plain Polygon (no curve rings, no holes), and they meet at two
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,15 @@ public void testHShellComplementaryHalfDiscsAreTheDisc() throws Exception {
// form without a noder.
assertNull("H-SHELL-N-MIXED: collinear overlap stays refused",
CompoundCurveShellOverlay.overlay(upper, onDiameter, OverlayNG.INTERSECTION));
Geometry stadiumNest = readCurve(
"CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 -1, -2 0, -1 1), (-1 1, 1 1), CIRCULARSTRING (1 1, 2 0, 1 -1), (1 -1, -1 -1)))");
Geometry circle5 = readCurve(CIRCLE_5);
// Mixed stadium in CIRCLE_5 is not two discs. D4 / R1.7 stay
// null; do not punch a non-disc hole.
assertNull("CC-NEST-ANNULUS: mixed nest is not two discs",
CircularDiscOverlay.overlay(circle5, stadiumNest, OverlayNG.DIFFERENCE));
assertNull("CC-NEST-ANNULUS: R1.7 is two-node, not a 0-node punch",
CompoundCurveShellOverlay.overlay(circle5, stadiumNest, OverlayNG.DIFFERENCE));
Geometry oddStadium = readCurve(
"CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 4, 0 5, 1 4), (1 4, 1 -1), CIRCULARSTRING (1 -1, 0 -2, -1 -1), (-1 -1, -1 4)))");
OverlayNGCurve oddCap = new OverlayNGCurve(upper, oddStadium);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,14 @@
/**
* R1.5: two crossing circular discs become lens / blob / crescents, exact,
* and JTS-class with the chord overlay. Nested concentric discs become
* the annulus (the two-disc 7/8 · 6/8 remainder). An internal tangent
* nest ({@code H-ANNULUS-TANGENT}) is not strictly inside and stays
* {@code null}. Disjoint and non-disc pairs stay {@code null} so
* OverlayNGCurve can take R2 without paying this path first.
* the annulus (the two-disc 7/8 · 6/8 remainder), including a
* CompoundCurve of two semicircle arcs that certifies as a disc.
* An internal tangent nest ({@code H-ANNULUS-TANGENT}) is not
* strictly inside and stays {@code null}. A mixed CompoundCurve
* nest ({@code CC-NEST-ANNULUS}: stadium in a disc) is not two
* discs and stays {@code null}. Disjoint and non-disc pairs stay
* {@code null} so OverlayNGCurve can take R2 without paying this
* path first.
*/
public class CircularDiscOverlayTest extends GeometryTestCase {

Expand All @@ -42,6 +46,20 @@ public class CircularDiscOverlayTest extends GeometryTestCase {
"CURVEPOLYGON (CIRCULARSTRING (2 0, 7 5, 12 0, 7 -5, 2 0))";
private static final String CIRCLE_3 =
"CURVEPOLYGON (CIRCULARSTRING (-3 0, 0 3, 3 0, 0 -3, -3 0))";
/**
* Same disc as {@link #CIRCLE_3}, encoded as two semicircle
* CircularStrings. {@code circularDisc} certifies it; D4 owns
* the nest. Not a re-run of the five-point CIRCLE_3 WKT.
*/
private static final String CIRCLE_3_CC =
"CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-3 0, 0 3, 3 0), CIRCULARSTRING (3 0, 0 -3, -3 0)))";
/**
* Horizontal stadium |x|≤2, |y|≤1, strictly inside CIRCLE_5.
* Mixed CompoundCurve (arcs + segments). Not a disc. 0 nodes.
* Not HALF_SMALL, not H-ANNULUS-TANGENT, not CIRCLE_3.
*/
private static final String STADIUM_NEST =
"CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 -1, -2 0, -1 1), (-1 1, 1 1), CIRCULARSTRING (1 1, 2 0, 1 -1), (1 -1, -1 -1)))";
/** r=3 at (2,0); internally tangent to CIRCLE_5 at (5,0). */
private static final String CIRCLE_INT_TAN =
"CURVEPOLYGON (CIRCULARSTRING (-1 0, 2 3, 5 0, 2 -3, -1 0))";
Expand Down Expand Up @@ -139,6 +157,76 @@ public void testNestedCoveredBySubIsExactEmpty() throws Exception {
assertTrue(sub.isEmpty());
}

/**
* Two-arc CompoundCurve disc vs CIRCLE_5: both certify, so this is
* D4, not a new noder. Kit-level lock so it cannot silently become R2.
*/
public void testCompoundCurveDiscNestIsExactAnnulus() throws Exception {
Geometry outer = readCurve(CIRCLE_5);
Geometry inner = readCurve(CIRCLE_3_CC);
assertNotNull("two-arc CompoundCurve certifies as a disc",
CircularDiscOverlay.centreRadius(inner));
assertNotNull("kit-level: CAP cannot silently become R2",
CircularDiscOverlay.overlay(outer, inner, OverlayNG.INTERSECTION));
assertNotNull("kit-level: CUP cannot silently become R2",
CircularDiscOverlay.overlay(outer, inner, OverlayNG.UNION));
assertNotNull("kit-level: SUB cannot silently become R2",
CircularDiscOverlay.overlay(outer, inner, OverlayNG.DIFFERENCE));
assertNotNull("kit-level: XOR cannot silently become R2",
CircularDiscOverlay.overlay(outer, inner, OverlayNG.SYMDIFFERENCE));

OverlayNGCurve cap = new OverlayNGCurve(outer, inner);
Geometry common = cap.getResult(OverlayNG.INTERSECTION);
assertFalse("CAP is exact", cap.isApproximate());
assertEquals("inner disc", 9.0 * Math.PI, common.getArea(), EXACT);

OverlayNGCurve cup = new OverlayNGCurve(outer, inner);
Geometry cover = cup.getResult(OverlayNG.UNION);
assertFalse("CUP is exact", cup.isApproximate());
assertEquals("outer disc", 25.0 * Math.PI, cover.getArea(), EXACT);

OverlayNGCurve rev = new OverlayNGCurve(inner, outer);
Geometry empty = rev.getResult(OverlayNG.DIFFERENCE);
assertFalse("small \\ large is exact", rev.isApproximate());
assertTrue(empty.isEmpty());

assertAnnulus(OverlayNG.DIFFERENCE, CIRCLE_5, CIRCLE_3_CC, 16.0 * Math.PI);
assertAnnulus(OverlayNG.SYMDIFFERENCE, CIRCLE_5, CIRCLE_3_CC,
16.0 * Math.PI);
assertAnnulus(OverlayNG.SYMDIFFERENCE, CIRCLE_3_CC, CIRCLE_5,
16.0 * Math.PI);
}

/**
* Mixed CompoundCurve nest (stadium in a CircularString disc) is
* not two certified discs. D4 and R1.7 return null. Named miss,
* not a laser. Public overlay may chordsaw.
*/
public void testMixedCompoundCurveNestIsNamedMiss() throws Exception {
Geometry outer = readCurve(CIRCLE_5);
Geometry stadium = readCurve(STADIUM_NEST);
assertNull("inner stadium is not a disc",
CircularDiscOverlay.centreRadius(stadium));
// D4 punches only certified discs. A stadium hole is not that
// closed form; do not invent a CompoundCurve annulus noder.
assertNull("CC-NEST-ANNULUS: mixed nest is not two discs; D4 stays null",
CircularDiscOverlay.overlay(outer, stadium, OverlayNG.DIFFERENCE));
assertNull("CC-NEST-ANNULUS: reverse nest is the same miss",
CircularDiscOverlay.overlay(stadium, outer, OverlayNG.DIFFERENCE));
// R1.7 clip() is two-node only. TwoShellClip never runs: only
// one operand is a mixed CompoundCurve shell.
assertNull("CC-NEST-ANNULUS: R1.7 is two-node; 0-node mixed-vs-disc is not a punch",
CompoundCurveShellOverlay.overlay(outer, stadium, OverlayNG.DIFFERENCE));
assertNull("CC-NEST-ANNULUS: reverse R1.7 is the same miss",
CompoundCurveShellOverlay.overlay(stadium, outer, OverlayNG.DIFFERENCE));

OverlayNGCurve sub = new OverlayNGCurve(outer, stadium);
Geometry saw = sub.getResult(OverlayNG.DIFFERENCE);
assertFalse("the chordsaw still answers", saw.isEmpty());
assertTrue("CC-NEST-ANNULUS: public SUB is the chordsaw, not a laser",
sub.isApproximate());
}

/**
* The laser result is two CircularString members, not a densified ring,
* and matches the chord overlay's topology and area.
Expand Down Expand Up @@ -178,10 +266,10 @@ private void assertAnnulus(int opCode, String wktA, String wktB,
assertEquals("CurvePolygon", laser.getGeometryType());
CurvePolygon cp = (CurvePolygon) laser;
assertEquals("one hole", 1, cp.getNumInteriorRing());
assertTrue("outer is a CircularString",
cp.getExteriorCurve() instanceof CircularString);
assertTrue("hole is a CircularString",
cp.getInteriorCurveN(0) instanceof CircularString);
assertTrue("outer is a circular disc ring",
isCircularDiscRing(cp.getExteriorCurve()));
assertTrue("hole is a circular disc ring",
isCircularDiscRing(cp.getInteriorCurveN(0)));
assertEquals("ten control points, not a densified ring",
10, laser.getNumPoints());
assertEquals("exact closed-form area", exactArea, laser.getArea(), EXACT);
Expand All @@ -196,6 +284,30 @@ private void assertAnnulus(int opCode, String wktA, String wktB,
hd <= AREA_TOL);
}

/**
* A certified disc ring: a closed CircularString, or a CompoundCurve
* of only CircularString members. A mixed stadium is not this.
*/
private static boolean isCircularDiscRing(LineString ring) {
if (ring instanceof CircularString) {
return true;
}
if (!(ring instanceof CompoundCurve)) {
return false;
}
CompoundCurve cc = (CompoundCurve) ring;
if (cc.getNumMembers() == 0) {
return false;
}
boolean allArcs = true;
for (int i = 0; i < cc.getNumMembers() && allArcs; i++) {
if (!(cc.getMemberN(i) instanceof CircularString)) {
allArcs = false;
}
}
return allArcs;
}

private static void assertTwoArcShell(Geometry g) {
assertEquals("CurvePolygon", g.getGeometryType());
LineString shell = ((CurvePolygon) g).getExteriorCurve();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,12 @@ public void testNotThisCellReturnsNull() throws Exception {
CompoundCurveShellOverlay.overlay(half, onDiameter, OverlayNG.INTERSECTION));
assertNull("line-only shell",
CompoundCurveShellOverlay.overlay(chords, square, OverlayNG.INTERSECTION));
Geometry stadiumNest = readCurve(
"CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 -1, -2 0, -1 1), (-1 1, 1 1), CIRCULARSTRING (1 1, 2 0, 1 -1), (1 -1, -1 -1)))");
// Mixed stadium strictly inside CIRCLE_5: not two discs, and
// clip() is two-node only. TwoShellClip never runs. Named miss.
assertNull("CC-NEST-ANNULUS: 0-node mixed-vs-disc is not a punch",
CompoundCurveShellOverlay.overlay(disc, stadiumNest, OverlayNG.DIFFERENCE));
}

private static void assertParity(Geometry a, Geometry b, int opCode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
* Exact cells per operation: CAP 8 of 8, CUP 8, SUB 8, XOR 8 on the
* two-disc matrix. Crossing discs are two-arc CurvePolygons (R1.5).
* Nested discs are the annulus (R1.5): SUB the outer with the inner
* as a hole, XOR the same. A disc clipped by a plain rectangle (R1.6)
* as a hole, XOR the same, including a two-arc CompoundCurve disc.
* A disc clipped by a plain rectangle (R1.6)
* is EEEE in both operand orders. A half-disc CompoundCurve shell vs
* a crossing disc or a cutting square (R1.7) is EEEE in both operand
* orders. Two crossing CircularStrings (R-AA) are EEEE in both operand
Expand All @@ -60,6 +61,9 @@ public class OverlayNGCurveRatchetTest extends GeometryTestCase {
"CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))";
private static final String CIRCLE_3 =
"CURVEPOLYGON (CIRCULARSTRING (-3 0, 0 3, 3 0, 0 -3, -3 0))";
/** Same disc as CIRCLE_3, two semicircle CircularStrings. */
private static final String CIRCLE_3_CC =
"CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-3 0, 0 3, 3 0), CIRCULARSTRING (3 0, 0 -3, -3 0)))";
private static final String CIRCLE_FAR =
"CURVEPOLYGON (CIRCULARSTRING (100 0, 105 5, 110 0, 105 -5, 100 0))";
private static final String CIRCLE_CROSSING =
Expand Down Expand Up @@ -172,6 +176,10 @@ public void testMatrix_covers() throws Exception {
assertRow("covers", CIRCLE_5, CIRCLE_3, "EEEE");
}

public void testMatrix_coversCompoundCurveDisc() throws Exception {
assertRow("covers two-arc disc", CIRCLE_5, CIRCLE_3_CC, "EEEE");
}

public void testMatrix_coveredBy() throws Exception {
assertRow("coveredBy", CIRCLE_3, CIRCLE_5, "EE0E");
}
Expand Down
Loading