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 @@ -39,9 +39,10 @@
* inside ({@code H-ANNULUS-TANGENT}: internal tangent, 1 node,
* 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.
* in a disc; D4 stays null, R1.7 may punch it) -- returns
* {@code null} so the caller can take the next rung 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 @@ -42,7 +42,9 @@
* SUB / XOR the paired caps and ears) -- not a general noder.
* Anything else -- not this shape pair, holes, 0 / 1 / odd nodes, a
* non-alternating cut -- returns {@code null} so the caller can take
* the chord baseline without paying this path first.
* the chord baseline without paying this path first. A 0-node
* covering square minus a disc ({@code R1.6-honesty}) is that miss:
* not a disc-in-square punch, public overlay stays the chordsaw.
*/
final class CircularDiscPolygonOverlay {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.util.List;

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
Expand All @@ -36,8 +37,12 @@
* 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 0-node mixed shell vs a circular disc
* ({@code CC-NEST-ANNULUS}) is not a punch. A miss is {@code null}.
* / noder, not a kit). A 0-node mixed shell vs a CircularString
* disc is a dispatch gap, not missing math: D4
* {@code nestedAnnulus} and {@link TwoShellClip} already call
* {@link HalfDiscOverlay#containedShell}. This cell
* is only that type pair ({@code CC-NEST-ANNULUS}). A 1-node
* tangent stays {@code null}. A miss is {@code null}.
*/
final class CompoundCurveShellOverlay {

Expand Down Expand Up @@ -81,6 +86,10 @@ static Geometry overlay(Geometry a, Geometry b, int opCode) {

double[] disc = CircularDiscOverlay.centreRadius(other);
if (disc != null) {
Geometry nest = mixedNestPunch(shell, other, disc, shellFirst, opCode, a);
if (nest != null) {
return nest;
}
return clip(shell, new DiscOther(disc), shellFirst, opCode, a);
}
if (TwoNodeClip.isPlainPolygon(other)) {
Expand Down Expand Up @@ -123,6 +132,120 @@ else if (m instanceof LineString) {
return cp;
}

/**
* Dispatch gap: mixed CompoundCurve vs CircularString disc.
* Not missing math -- {@link HalfDiscOverlay#containedShell} is
* the product. Not D4 (the stadium is not a disc). Not
* {@link TwoShellClip}'s sample walk: that path's
* {@code shellSample} collapses when both envelopes are
* centered at the origin. Certificate is disc-aware: 0 nodes,
* {@code centreRadius} already in hand, and the stadium
* strictly inside (envelope vs {@code r − eps}, or control
* points plus cap extrema). A 1-node tangent is not this cell.
*/
private static Geometry mixedNestPunch(CurvePolygon shell, Geometry other,
double[] disc, boolean shellFirst, int opCode, Geometry factorySrc) {
List<TwoNodeClip.Edge> edges = TwoNodeClip.flatten(shell);
if (edges == null) return null;
List<TwoNodeClip.Node> nodes = TwoNodeClip.nodesVsDisc(edges, disc[0],
disc[1], disc[2]);
if (nodes == null || !nodes.isEmpty()) return null;
if (!shellInsideDisc(shell, edges, disc[0], disc[1], disc[2])) {
return null;
}
CurvePolygon discPoly = holeFreeCurvePolygon(other);
if (discPoly == null) return null;
return HalfDiscOverlay.containedShell(shell, discPoly, shellFirst, opCode,
factorySrc, TwoNodeClip.curveFactory(factorySrc));
}

/**
* Disc-aware inside test. Not a sample of both shells: those
* land on (0,0) for this fixture. Envelope vs {@code r − eps}
* is the cheap radii analog; control points plus each cap's
* outer pole cover a stadium whose AABB corners sit outside
* the circle.
*/
private static boolean shellInsideDisc(CurvePolygon shell,
List<TwoNodeClip.Edge> edges, double cx, double cy, double r) {
double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * r, 1.0e-12);
double lim = r - eps;
if (envelopeInsideDisc(shell, cx, cy, lim)) {
return true;
}
return controlsAndCapExtremaInside(edges, cx, cy, lim);
}

private static boolean envelopeInsideDisc(CurvePolygon shell, double cx,
double cy, double lim) {
Envelope env = shell.getEnvelopeInternal();
double dx = Math.max(Math.abs(env.getMinX() - cx),
Math.abs(env.getMaxX() - cx));
double dy = Math.max(Math.abs(env.getMinY() - cy),
Math.abs(env.getMaxY() - cy));
return Math.hypot(dx, dy) <= lim;
}

private static boolean controlsAndCapExtremaInside(
List<TwoNodeClip.Edge> edges, double cx, double cy, double lim) {
boolean inside = true;
for (int i = 0; i < edges.size() && inside; i++) {
TwoNodeClip.Edge e = edges.get(i);
if (!pointInsideDisc(e.a, cx, cy, lim)
|| !pointInsideDisc(e.b, cx, cy, lim)) {
inside = false;
}
else if (e.isArc) {
if (e.mid != null && !pointInsideDisc(e.mid, cx, cy, lim)) {
inside = false;
}
else if (!capExtremumInside(e, cx, cy, lim)) {
inside = false;
}
}
}
return inside;
}

/**
* Outer pole of the cap: the supporting-circle point in the
* direction from the disc centre through the arc centre. On
* the sweep it is the cap extremum; off the sweep it is not
* a boundary point.
*/
private static boolean capExtremumInside(TwoNodeClip.Edge e, double cx,
double cy, double lim) {
double vx = e.circle[0] - cx;
double vy = e.circle[1] - cy;
double n = Math.hypot(vx, vy);
if (n == 0.0) {
return e.circle[2] <= lim;
}
Coordinate pole = new Coordinate(
e.circle[0] + e.circle[2] * vx / n,
e.circle[1] + e.circle[2] * vy / n);
if (!TwoNodeClip.isOnSweep(pole, e.circle, e.a, e.mid, e.b)) {
return true;
}
return pointInsideDisc(pole, cx, cy, lim);
}

private static boolean pointInsideDisc(Coordinate p, double cx, double cy,
double lim) {
return Math.hypot(p.x - cx, p.y - cy) <= lim;
}

private static CurvePolygon holeFreeCurvePolygon(Geometry g) {
if (g instanceof MultiSurface) {
if (g.getNumGeometries() != 1) return null;
g = g.getGeometryN(0);
}
if (!(g instanceof CurvePolygon)) return null;
CurvePolygon cp = (CurvePolygon) g;
if (cp.isEmpty() || cp.getNumInteriorRing() > 0) return null;
return cp;
}

/**
* One two-node walk. The partner supplies nodes, scale, side-of,
* and the other-side pieces; the shell walk is always the typed
Expand All @@ -133,7 +256,6 @@ 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 @@ -76,15 +76,18 @@
* 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,
* a mixed CompoundCurve nest ({@code CC-NEST-ANNULUS}), or
* a non-disc, falls through without paying this path.</li>
* a mixed CompoundCurve nest ({@code CC-NEST-ANNULUS}: not two
* discs, so not D4), or a non-disc, falls through without paying
* this path. R1.7 may still punch that mixed nest.</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
* proper line–circle nodes. The answer is a {@link CurvePolygon}
* (or a {@link MultiSurface} for XOR) that keeps the surviving arcs.
* Closed form; no densification. An even run of 4+ alternating
* line–circle nodes is the same assemble with n spans. Any other
* pair returns {@code null} without paying this path.</li>
* line–circle nodes is the same assemble with n spans. A 0-node
* covering square minus a disc ({@code R1.6-honesty}) is not a
* punch: public overlay stays the chordsaw. Any other pair
* returns {@code null} without paying this path.</li>
* <li><b>R1.7</b> -- one operand is a hole-free {@link CurvePolygon} whose
* shell is a mixed {@link org.locationtech.jts.geom.curve.CompoundCurve}
* (LineString + CircularString: a half-disc or stadium) and the other
Expand All @@ -99,9 +102,13 @@
* half-lens, or a point-touch. Any other two hole-free
* CompoundCurve shells with exactly two proper nodes walk the
* surviving pieces; 0 / 1 node is containment or a disjoint
* touch. An even 4+ alternating cut of two CompoundCurve shells
* is the H-FOUR n-span assemble. Two crossings plus a tangent
* is the same assemble with the touch as a zero-length span.
* touch. A 0-node mixed shell strictly inside a circular disc
* is the nest punch ({@code CC-NEST-ANNULUS}: P2.3 cousin, not
* a noder, not D4): CAP the inner, CUP the outer, SUB / XOR
* {@code CurvePolygon(outer, [inner])}. An even 4+ alternating
* cut of two CompoundCurve shells is the H-FOUR n-span assemble.
* Two crossings plus a tangent is the same assemble with the
* touch as a zero-length span.
* A same-outer hole-inside pair
* is the holed / unholed / hole polygon. A different-outer hole
* whose outers already clip composes: hole strictly inside the
Expand Down Expand Up @@ -238,8 +245,9 @@ public OverlayNGCurve(Geometry a, Geometry b) {
* whose only non-alternation is a tangent (degenerate NSpan),
* a same-outer
* hole-inside pair, a different-outer hole composed from a
* certified outer clip, and an even 4+ line–circle cut of a disc
* by a plain polygon. In
* certified outer clip, an even 4+ line–circle cut of a disc
* by a plain polygon, and a 0-node mixed nest punch of a
* CompoundCurve shell strictly inside a disc. In
* the R1 case the <em>answer</em> is exact even though the <em>decision</em>
* to return it was made on densified copies.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,11 +396,11 @@ public void testHShellComplementaryHalfDiscsAreTheDisc() throws Exception {
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.
// Mixed stadium in CIRCLE_5 is not two discs. D4 stays null.
// R1.7 punches the 0-node nest (P2.3 cousin, not a noder).
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",
assertNotNull("CC-NEST-ANNULUS: R1.7 punches the 0-node mixed nest",
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)))");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@
* 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.
* discs: D4 stays {@code null}; R1.7 punches it. Disjoint and
* non-disc pairs stay {@code null} so OverlayNGCurve can take the
* next rung without paying this path first.
*/
public class CircularDiscOverlayTest extends GeometryTestCase {

Expand Down Expand Up @@ -199,10 +199,12 @@ public void testCompoundCurveDiscNestIsExactAnnulus() throws Exception {

/**
* 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.
* not two certified discs. D4 stays null. R1.7 punches it
* after R1.5 / R1.6 miss: CAP 4+π, CUP 25π, SUB / XOR 24π−4,
* reverse SUB empty. Do not re-encode the stadium as a two-arc
* disc.
*/
public void testMixedCompoundCurveNestIsNamedMiss() throws Exception {
public void testMixedCompoundCurveNestIsPunchNotD4() throws Exception {
Geometry outer = readCurve(CIRCLE_5);
Geometry stadium = readCurve(STADIUM_NEST);
assertNull("inner stadium is not a disc",
Expand All @@ -211,20 +213,43 @@ public void testMixedCompoundCurveNestIsNamedMiss() throws Exception {
// 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",
assertNull("CC-NEST-ANNULUS: reverse nest is the same D4 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",

assertNotNull("CC-NEST-ANNULUS: R1.7 punches the 0-node mixed nest",
CompoundCurveShellOverlay.overlay(outer, stadium, OverlayNG.DIFFERENCE));
assertNull("CC-NEST-ANNULUS: reverse R1.7 is the same miss",
CompoundCurveShellOverlay.overlay(stadium, outer, OverlayNG.DIFFERENCE));
assertNotNull("CC-NEST-ANNULUS: reverse CAP is the inner stadium",
CompoundCurveShellOverlay.overlay(stadium, outer, OverlayNG.INTERSECTION));

OverlayNGCurve cap = new OverlayNGCurve(outer, stadium);
Geometry common = cap.getResult(OverlayNG.INTERSECTION);
assertFalse("mixed nest CAP is exact", cap.isApproximate());
assertEquals("CAP is the stadium, 4+π", 4.0 + Math.PI, common.getArea(),
EXACT);

OverlayNGCurve cup = new OverlayNGCurve(outer, stadium);
Geometry cover = cup.getResult(OverlayNG.UNION);
assertFalse("mixed nest CUP is exact", cup.isApproximate());
assertEquals("CUP is CIRCLE_5, 25π", 25.0 * Math.PI, cover.getArea(), EXACT);

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

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",
Geometry punched = sub.getResult(OverlayNG.DIFFERENCE);
assertFalse("CC-NEST-ANNULUS: public SUB is the laser, not a chordsaw",
sub.isApproximate());
assertEquals("CurvePolygon", punched.getGeometryType());
CurvePolygon cp = (CurvePolygon) punched;
assertEquals("one hole", 1, cp.getNumInteriorRing());
assertTrue("outer stays a CircularString disc ring",
cp.getExteriorCurve() instanceof CircularString);
assertTrue("hole stays the CompoundCurve stadium, not a densified n-gon",
cp.getInteriorCurveN(0) instanceof CompoundCurve);
assertEquals("SUB is 24π − 4", 24.0 * Math.PI - 4.0, punched.getArea(),
EXACT);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,18 @@
* nodes. CAP / CUP / SUB / XOR keep the surviving arc, exact, and
* JTS-class with the chord overlay. Anything else is {@code null} so
* OverlayNGCurve can take R2 without paying this path first.
* A covering square minus a concentric disc has 0 line–circle
* nodes ({@code R1.6-honesty}): keep the miss, do not punch.
*/
public class CircularDiscPolygonOverlayTest extends GeometryTestCase {

private static final String CIRCLE_5 =
"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))";
/** Covering square: 0 line–circle nodes on CIRCLE_3. */
private static final String PLAIN_SQUARE =
"POLYGON ((-6 -6, 6 -6, 6 6, -6 6, -6 -6))";
private static final String CIRCLE_CROSSING =
"CURVEPOLYGON (CIRCULARSTRING (2 0, 7 5, 12 0, 7 -5, 2 0))";
/** Axis-aligned half-plane cut: the right half of CIRCLE_5. */
Expand Down Expand Up @@ -125,6 +132,25 @@ public void testTriangleTwoChordNodes() throws Exception {
revCap.getArea(), EXACT);
}

/**
* Named R1.6-honesty stamp. Covering PLAIN_SQUARE minus
* CIRCLE_3 has 0 line–circle nodes, so this cell misses.
* Public overlay stays the chordsaw. Do not expand R1.6 past
* two-node / even-n. Not a disc-in-square nest punch. Not D4.
*/
public void testR16HonestyCoveringSquareMinusDiscIsNamedMiss()
throws Exception {
Geometry square = readCurve(PLAIN_SQUARE);
Geometry inner = readCurve(CIRCLE_3);
assertNull("R1.6-honesty: overlay(PLAIN_SQUARE, CIRCLE_3, SUB) is null",
CircularDiscPolygonOverlay.overlay(square, inner, OverlayNG.DIFFERENCE));
OverlayNGCurve sub = new OverlayNGCurve(square, inner);
Geometry saw = sub.getResult(OverlayNG.DIFFERENCE);
assertFalse("the chordsaw still answers", saw.isEmpty());
assertTrue("R1.6-honesty: public isApproximate=true",
sub.isApproximate());
}

public void testNotDiscAndPlainPolygonReturnsNull() throws Exception {
Geometry disc = readCurve(CIRCLE_5);
Geometry other = readCurve(CIRCLE_CROSSING);
Expand Down
Loading
Loading