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
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,13 @@
* SUB / XOR the outer with the inner as a hole. Same closed form as
* {@link HalfDiscOverlay#containedShell}; not a noder. Anything else
* -- not both discs, 1 intersection, a nest that is not strictly
* inside -- returns {@code null} so the caller can take the chord
* baseline without paying this path first.
* 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; D4 stays null, R1.7 may punch it) -- 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 @@ -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 @@ -39,8 +40,12 @@
* other.shell is a bite, not a punch),
* {@link TwoHoleOverlay} (two holes that cross on the same outer),
* or a two-node walk vs a disc or plain polygon via
* {@link TwoNodeClip}. A 0-node mixed shell vs a circular disc
* ({@code CC-NEST-ANNULUS}) is not a punch. A clothoid member is
* {@link TwoNodeClip}. 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 clothoid member is
* {@link ClothoidOverlay} (0-node identity / disjoint / nest) or
* a named Fresnel miss -- never a chord flatten. A miss is
* {@code null}.
Expand Down Expand Up @@ -98,6 +103,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 @@ -143,6 +152,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 @@ -153,7 +276,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 @@ -75,15 +75,19 @@
* 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
* a non-disc, falls through without paying this path.</li>
* Closed form; no densification. 1 intersection, a tangent nest,
* 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 @@ -98,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 @@ -235,8 +243,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 @@ -422,6 +422,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 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));
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)))");
OverlayNGCurve oddCap = new OverlayNGCurve(upper, oddStadium);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@
/**
* 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). Disjoint and
* 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: D4 stays {@code null}; R1.7 punches it. Disjoint and
* non-disc pairs stay {@code null} so OverlayNGCurve can take R2
* without paying this path first.
*/
Expand All @@ -44,6 +49,13 @@ 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))";
/**
* Horizontal stadium |x|≤2, |y|≤1, strictly inside CIRCLE_5.
* Mixed CompoundCurve (arcs + segments). Not a disc. 0 nodes.
* 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)))";
private static final String CIRCLE_FAR =
"CURVEPOLYGON (CIRCULARSTRING (100 0, 105 5, 110 0, 105 -5, 100 0))";
private static final String PLAIN_SQUARE =
Expand Down Expand Up @@ -143,13 +155,68 @@ public void testThreePointCircleIsExactDiscKit() throws Exception {
public void testUxThreePointCircleIsExactDisc() throws Exception {
Geometry g = readCurve(
"CURVEPOLYGON (CIRCULARSTRING (210 560, 560 700, 460 410, 210 560))");
OverlayNGCurve cap = new OverlayNGCurve(g, g);
Geometry self = cap.getResult(OverlayNG.INTERSECTION);
OverlayNGCurve uxCap = new OverlayNGCurve(g, g);
Geometry self = uxCap.getResult(OverlayNG.INTERSECTION);
assertFalse("UX 4-control circle must be a disc kit, not chainsaw",
cap.isApproximate());
uxCap.isApproximate());
assertEquals(g.getArea(), self.getArea(), EXACT);
}

/**
* Mixed CompoundCurve nest (stadium in a CircularString disc) is
* 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 testMixedCompoundCurveNestIsPunchNotD4() 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 D4 miss",
CircularDiscOverlay.overlay(stadium, outer, OverlayNG.DIFFERENCE));

assertNotNull("CC-NEST-ANNULUS: R1.7 punches the 0-node mixed nest",
CompoundCurveShellOverlay.overlay(outer, stadium, 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 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);
}

/**
* The laser result is two CircularString members, not a densified ring,
* and matches the chord overlay's topology and area.
Expand Down
Loading
Loading