diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveExact.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveExact.java index ac85ed7a16..7651f4f923 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveExact.java +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveExact.java @@ -35,7 +35,7 @@ * a Point or MultiPoint (PIP and DE-9IM), a disc against a LineString * (DE-9IM from line–circle nodes), a disc against a plain Polygon * (DE-9IM from vertices, edge nodes, and mid-arc PIP), or two circular - * discs (DE-9IM from radical-axis nodes and {@link #locatePoint}). + * discs (DE-9IM from {@code d²} vs {@code (r1±r2)²} in {@code R²}). * Package-private -- not a new public API. * {@link CurveOps} takes these only when they can answer; anything else * goes straight to the chord baseline. Trying and falling through would @@ -201,9 +201,9 @@ static Boolean covers(Geometry curve, Geometry other) { * lineal and polygonal use * {@link CircularArcDensifier#intersectSegmentCircle} * ({@code t ∈ [0,1]}, the R1.6 / {@code ARC_SEGMENT_XY} quadratic) - * plus endpoint / vertex location. Two discs reuse - * {@link CircularArcDensifier#intersectCircles} (radical axis) and - * locate each centre in the other disc. A polygonal miss also samples + * plus endpoint / vertex location. Two discs classify in {@code R²} + * ({@code d²} vs {@code (r1±r2)²}); the kiss coordinates are not + * needed for the matrix. A polygonal miss also samples * mid-arc points in the polygon (jts-core PIP). Mixed MultiPoint * location classes, a multi-member MultiLineString or MultiSurface, * a holed or non-disc curve polygon return {@code null} so the caller @@ -342,10 +342,14 @@ private static LineString plainLine(Geometry g) { /** * Location classes of two circular discs. Envelope miss is disjoint. - * Node count is {@link CircularArcDensifier#intersectCircles} (empty - * when {@code d == 0}, {@code d > r1+r2}, or {@code d < |r1-r2|}); - * each centre is then located in the other disc. A non-disc - * {@code CurvePolygon} never reaches this method. + * The class is decided in {@code R²}: {@code d²} against + * {@code (r1+r2)²} and {@code (r1-r2)²}. No {@code hypot} and no + * radical-axis node for the decision (V1, before noding). Public + * numbers may take {@code sqrt}; this matrix does not need the kiss + * coordinates. T-ext is {@link #IM_AREA_EXT_TANGENT} {@code FF2F01212} + * — one kiss, no shared flesh — ISO/IEC 13249-3 {@code ST_Touches} + * (DE-9IM {@code FT*******} / {@code F**T*****} / {@code F***T****}). + * A non-disc {@code CurvePolygon} never reaches this method. */ private static IntersectionMatrix relateDisc(CircularArcDensifier.Circle a, CircularArcDensifier.Circle b) { @@ -354,30 +358,33 @@ private static IntersectionMatrix relateDisc(CircularArcDensifier.Circle a, if (!ea.intersects(eb)) { return new IntersectionMatrix(IM_AREA_DISJOINT); } - Coordinate[] nodes = CircularArcDensifier.intersectCircles(a, b); - Coordinate ca = new Coordinate(a.cx, a.cy); - Coordinate cb = new Coordinate(b.cx, b.cy); - double r2a = a.r * a.r; - double r2b = b.r * b.r; - if (nodes.length == 2) { - return new IntersectionMatrix(IM_AREA_OVERLAP); - } - int locAinB = locatePoint(b, ca, r2b); - int locBinA = locatePoint(a, cb, r2a); - if (nodes.length == 1) { - if (locAinB == Location.EXTERIOR && locBinA == Location.EXTERIOR) { - return new IntersectionMatrix(IM_AREA_EXT_TANGENT); - } + double dx = a.cx - b.cx; + double dy = a.cy - b.cy; + double d2 = dx * dx + dy * dy; + double sum = a.r + b.r; + double sum2 = sum * sum; + double diff = a.r - b.r; + double diff2 = diff * diff; + if (d2 == 0.0 && a.r == b.r) { + return new IntersectionMatrix(IM_AREA_EQUAL); + } + if (d2 > sum2) { + return new IntersectionMatrix(IM_AREA_DISJOINT); + } + if (d2 == sum2) { + return new IntersectionMatrix(IM_AREA_EXT_TANGENT); + } + if (d2 == diff2) { return a.r > b.r ? new IntersectionMatrix(IM_AREA_INT_TANGENT) : new IntersectionMatrix(IntersectionMatrix.transpose(IM_AREA_INT_TANGENT)); } - if (locAinB == Location.EXTERIOR && locBinA == Location.EXTERIOR) { - return new IntersectionMatrix(IM_AREA_DISJOINT); + if (d2 < diff2) { + return a.r > b.r + ? new IntersectionMatrix(IM_AREA_COVERS) + : new IntersectionMatrix(IM_AREA_COVEREDBY); } - if (a.r > b.r) return new IntersectionMatrix(IM_AREA_COVERS); - if (a.r < b.r) return new IntersectionMatrix(IM_AREA_COVEREDBY); - return new IntersectionMatrix(IM_AREA_EQUAL); + return new IntersectionMatrix(IM_AREA_OVERLAP); } /** diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveExactRelateDiscTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveExactRelateDiscTest.java index c7ef874b10..855f56c982 100644 --- a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveExactRelateDiscTest.java +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveExactRelateDiscTest.java @@ -152,6 +152,11 @@ public void testNestedOffsetCovers() throws Exception { assertTrue(inner.coveredBy(big)); } + /** + * Axis-aligned pair kisses at the shared control {@code (5, 0)}. + * R.1 ({@link CurveExactRelateTouchTest}) pins the same matrix on a + * 3-4-5 pair whose kiss is not a control vertex. + */ public void testExternalTangent() throws Exception { Geometry a = readCurve(CIRCLE_5); Geometry b = readCurve(CIRCLE_EXT_TAN); diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveExactRelateTouchTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveExactRelateTouchTest.java new file mode 100644 index 0000000000..0ec8f3fb1a --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveExactRelateTouchTest.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import java.lang.reflect.Field; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.IntersectionMatrix; +import org.locationtech.jts.io.curve.CurveWKTReader; +import org.locationtech.jts.operation.overlayng.curve.OverlayNGCurve; +import org.locationtech.jts.operation.relate.RelateOp; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * R.1 TOUCH laser cell. Two externally tangent circular discs: + * one kiss, no shared flesh. T-ext DE-9IM {@code FF2F01212}. + *

+ * ISO/IEC 13249-3 {@code ST_Touches}: the only points in common lie + * on the boundaries (DE-9IM {@code FT*******} / {@code F**T*****} / + * {@code F***T****}). No DOI. Stamp: EXACT. V1 before noding — the + * class is {@code d² == (r1+r2)²} in {@code R²}, not a hypot + * quotient and not a noder. Public {@link Geometry#touches} is the + * RelateOp predicate ({@link IntersectionMatrix#isTouches}) on that + * matrix. No {@code OverlayNGCurve.TOUCH}. No fifth overlay op. + *

+ * The axis-aligned pair in {@link CurveExactRelateDiscTest} kisses at + * a shared control vertex, so the control diamonds also touch. This + * cell pins a 3-4-5 pair whose kiss {@code (4, 3)} is not a control: + * RelateOp on the control polygons is the lie (disjoint). + */ +public class CurveExactRelateTouchTest extends GeometryTestCase { + + private static final String CIRCLE_5 = + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"; + /** Centre (8, 6), r=5. Externally tangent to CIRCLE_5; kiss (4, 3). */ + private static final String CIRCLE_3_4_5 = + "CURVEPOLYGON (CIRCULARSTRING (13 6, 8 11, 3 6, 8 1, 13 6))"; + private static final String MULTI_5 = "MULTISURFACE (" + CIRCLE_5 + ")"; + private static final String MULTI_345 = "MULTISURFACE (" + CIRCLE_3_4_5 + ")"; + private static final String CONTROL_5 = + "POLYGON ((-5 0, 0 5, 5 0, 0 -5, -5 0))"; + private static final String CONTROL_345 = + "POLYGON ((13 6, 8 11, 3 6, 8 1, 13 6))"; + + /** T-ext: II=F, BB=0. One kiss, no shared flesh. */ + private static final String IM_EXT = CurveExact.IM_AREA_EXT_TANGENT; + + public static void main(String[] args) { + TestRunner.run(CurveExactRelateTouchTest.class); + } + + public CurveExactRelateTouchTest(String name) { super(name); } + + private static Geometry readCurve(String wkt) throws Exception { + return new CurveWKTReader(new CurveGeometryFactory()).read(wkt); + } + + public void testMatrixIsFF2F01212() { + assertEquals("FF2F01212", IM_EXT); + assertTrue("SFS / RelateOp Touch on T-ext", + new IntersectionMatrix(IM_EXT).isTouches(2, 2)); + } + + public void testR2KissIsExactEquality() throws Exception { + Geometry a = readCurve(CIRCLE_5); + Geometry b = readCurve(CIRCLE_3_4_5); + CircularArcDensifier.Circle da = CurveExact.circularDisc(a); + CircularArcDensifier.Circle db = CurveExact.circularDisc(b); + assertNotNull(da); + assertNotNull(db); + double dx = da.cx - db.cx; + double dy = da.cy - db.cy; + double d2 = dx * dx + dy * dy; + double sum2 = (da.r + db.r) * (da.r + db.r); + assertEquals("3-4-5: d² = 8²+6² = 100", 100.0, d2, 0.0); + assertEquals("(r1+r2)² = 10²", 100.0, sum2, 0.0); + assertEquals("T-ext is d² == (r1+r2)², no hypot", + Double.doubleToRawLongBits(d2), Double.doubleToRawLongBits(sum2)); + } + + public void testPublicRelateAndTouches() throws Exception { + Geometry a = readCurve(CIRCLE_5); + Geometry b = readCurve(CIRCLE_3_4_5); + assertEquals(IM_EXT, CurveExact.relate(a, b).toString()); + assertEquals(IM_EXT, CurveExact.relate(b, a).toString()); + assertEquals(IM_EXT, a.relate(b).toString()); + assertEquals(IM_EXT, b.relate(a).toString()); + assertTrue("Geometry.touches via RelateOp isTouches", a.touches(b)); + assertTrue(b.touches(a)); + assertTrue(a.relate(b).isTouches(a.getDimension(), b.getDimension())); + assertTrue(a.relate(b, IM_EXT)); + assertTrue(a.intersects(b)); + assertFalse("no shared flesh", a.overlaps(b)); + assertFalse(a.contains(b)); + assertFalse(a.covers(b)); + assertFalse(a.crosses(b)); + assertFalse(a.equalsTopo(b)); + } + + public void testControlPolygonRelateOpIsTheLie() throws Exception { + Geometry a = readCurve(CIRCLE_5); + Geometry b = readCurve(CIRCLE_3_4_5); + Geometry diamondA = read(CONTROL_5); + Geometry diamondB = read(CONTROL_345); + String lie = RelateOp.relate(diamondA, diamondB).toString(); + assertFalse("control diamonds miss the (4, 3) kiss", + IM_EXT.equals(lie)); + assertFalse("RelateOp on controls is not Touch", + diamondA.touches(diamondB)); + assertEquals("laser is not the control-polygon matrix", + IM_EXT, a.relate(b).toString()); + assertTrue(a.touches(b)); + } + + public void testSingleMemberMultiSurface() throws Exception { + Geometry ma = readCurve(MULTI_5); + Geometry mb = readCurve(MULTI_345); + Geometry b = readCurve(CIRCLE_3_4_5); + assertEquals(IM_EXT, CurveExact.relate(ma, b).toString()); + assertEquals(IM_EXT, ma.relate(b).toString()); + assertEquals(IM_EXT, CurveExact.relate(ma, mb).toString()); + assertEquals(IM_EXT, ma.relate(mb).toString()); + assertTrue(ma.touches(b)); + assertTrue(ma.touches(mb)); + } + + public void testNoOverlayNGCurveTouch() { + Field[] fields = OverlayNGCurve.class.getDeclaredFields(); + for (int i = 0; i < fields.length; i++) { + assertFalse("DE-9IM Touch only — no OverlayNGCurve.TOUCH", + "TOUCH".equals(fields[i].getName())); + } + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurvePerfGateTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurvePerfGateTest.java index 19060ec160..d4ab621bc2 100644 --- a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurvePerfGateTest.java +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurvePerfGateTest.java @@ -73,6 +73,9 @@ public class OverlayNGCurvePerfGateTest extends GeometryTestCase { "CURVEPOLYGON (CIRCULARSTRING (2 0, 7 5, 12 0, 7 -5, 2 0))"; private static final String CIRCLE_EXT_TAN = "CURVEPOLYGON (CIRCULARSTRING (5 0, 10 5, 15 0, 10 -5, 5 0))"; + /** R.1 T-ext kiss at (4, 3), not a control vertex. */ + private static final String CIRCLE_3_4_5 = + "CURVEPOLYGON (CIRCULARSTRING (13 6, 8 11, 3 6, 8 1, 13 6))"; private static final String EMPTY = "CURVEPOLYGON EMPTY"; private static final String POINT_INSIDE = "POINT (3 3)"; private static final String POINT_FAR = "POINT (100 100)"; @@ -762,6 +765,17 @@ public void testRelateExtTangentDiscsNotSlowerThanChord() throws Exception { () -> CurveOps.linearise(a).relate(CurveOps.linearise(tan))); } + public void testRelateR1TouchKissNotSlowerThanChord() throws Exception { + Geometry a = readCurve(CIRCLE_5); + Geometry kiss = readCurve(CIRCLE_3_4_5); + assertLaserNotSlower("R.1 T-ext 3-4-5 relate", + () -> a.relate(kiss), + () -> CurveOps.linearise(a).relate(CurveOps.linearise(kiss))); + assertLaserNotSlower("R.1 T-ext 3-4-5 touches", + () -> a.touches(kiss), + () -> CurveOps.linearise(a).touches(CurveOps.linearise(kiss))); + } + public void testRelateCrossingDiscsReverseNotSlowerThanChord() throws Exception { Geometry a = readCurve(CIRCLE_5); Geometry cross = readCurve(CIRCLE_CROSSING);