第08章 - 空间关系谓词与 DE-9IM

“这两个面相交吗?”“这个点在多边形里吗?”“这两条线是交叉还是仅仅端点相接?”——空间关系谓词是 GIS 业务里出现频率最高的一类操作。它们看起来只是几个返回 boolean 的方法,但背后是一套严格的拓扑学定义:DE-9IM 模型(Dimensionally Extended 9-Intersection Model)。

理解 DE-9IM 的价值在于:它让你能从“猜”变成“算”。当你发现 contains 返回 false 但直觉上应该为 true 时,能立刻想到“哦,被测几何有一部分落在边界上,那是 covers 而不是 contains”;当业务需要“共享一条边但不重叠”这种特殊关系时,能自己写出精度合适的 DE-9IM 模式,而不必反复试错。

本章分三层展开。第一层是理论:DE-9IM 的九个交集、维度符号、矩阵的读写。第二层是 API:JTS 的十个谓词方法、IntersectionMatrix 的操作、relate(pattern) 的自定义模式。第三层是工程:RelateNG(1.20 新增)带来的变化、PreparedGeometry 的加速、边界规则、空几何与 GeometryCollection 的特殊处理,以及十个最容易踩的语义陷阱。

本章所有矩阵与布尔结果都是在本机 jts-core 1.20.0 上实跑得到的,可以直接当对照表用。前置知识:第 4 章(几何对象模型)、第 17 章(有效性与 Coverage 质检)。


8.1 谓词家族全景

8.1.1 十个谓词方法

Geometry 上有十个拓扑谓词方法,全部返回 boolean:

方法 语义 对称性 常见误用
intersects(g) 有任意公共点(含边界) 对称 以为“不相交”才返回 false——空几何也返回 false
disjoint(g) 没有公共点 对称 与 intersects 互为反义,但空几何场景需注意
contains(g) g 完全在内部(不含边界) 非对称 点落在边界上时为 false
within(g) 自己在 g 内部 非对称 是 contains 的反向
covers(g) g 完全在内部或边界上 非对称 与 contains 的差别就在边界
coveredBy(g) 自己在 g 的内部或边界上 非对称 covers 的反向
touches(g) 有公共点但内部不相交 对称 面与面只共享边时为 true
crosses(g) 内部相交且结果维度低于两者 对称(同维)/非对称(异维) 线与面、线与线才有意义
overlaps(g) 内部相交但互不包含,且维度相同 对称 点与点、线与线、面与面
equalsTopo(g) 拓扑等价(点集相同) 对称 与 equalsExact 不同,不看坐标顺序
extbf{对称性很重要}:`contains`/`within`、`covers`/`coveredBy` 是两对互逆关系,写反了逻辑就完全相反。而 `intersects`/`disjoint`/`touches`/`overlaps` 是对称的,`crosses` 在“同维”时对称、“异维”时不对称(线与面交叉时,线 `crosses` 面为 true,面 `crosses` 线为 false)。

8.1.2 谓词之间的关系

                    ┌───────────────────────┐
                    │   intersects(g)       │  有任意公共点
                    └───────────┬───────────┘
                                │
          ┌─────────────────────┼─────────────────────┐
          ▼                     ▼                     ▼
  ┌───────────────┐     ┌───────────────┐     ┌───────────────┐
  │  touches(g)   │     │  crosses(g)   │     │  overlaps(g)  │
  │ 内部不相交     │     │ 内部相交       │     │ 同维且互不包含 │
  └───────────────┘     └───────────────┘     └───────────────┘
                                │
          ┌─────────────────────┴─────────────────────┐
          ▼                                           ▼
  ┌───────────────┐                           ┌───────────────┐
  │  covers(g)    │  内部或边界全包含           │  contains(g)  │  只在内部
  └───────┬───────┘                           └───────┬───────┘
          │                                           │
          └──────────────► equalsTopo(g) ◄────────────┘
                          (互相 covers + 同维度)

几条可推导的等价关系(对有效且非空的几何成立):

  • a.contains(b) ⟹ a.covers(b),反之不成立;
  • a.within(b) ⟺ b.contains(a);
  • a.coveredBy(b) ⟺ b.covers(a);
  • a.touches(b) ⟹ a.intersects(b) 且 !a.overlaps(b);
  • a.equalsTopo(b) ⟹ a.covers(b) && b.covers(a)。

8.1.3 为什么不要自己用坐标写判定

新手常见写法:

// 反例:手写点是否在矩形内
boolean inside = p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY;

这段代码的问题不是“不能跑”,而是语义不明:当 p.x == maxX 时,它算“在内部”还是“在边界上”?contains 要求严格内部,covers 允许边界。手写代码把这两种语义混在一起,后续一旦要区分就全得重写。

更重要的是:多边形不是矩形。真正的判定要处理凹多边形、洞、自相交(非法几何)等情况,JTS 的 PointLocator 与 RelateOp 用了十几年打磨的边扫描算法,手写几乎必然在边界用例上出错。


8.2 DE-9IM 理论

8.2.1 几何的三个部分

DE-9IM 的第一步是把每个几何拆成三部分:

部分 记法 定义 例子
内部 Interior (I) 去掉边界后的点集 面的内部、线的中间段、点自身
边界 Boundary (B) 拓扑边界 面的环、线的两个端点、点的边界为空
外部 Exterior (E) 平面中不属于该几何的点 无限大区域

几个容易记错的点:

  • 点的边界是空集(不是它自己);
  • 闭合线(环)的边界是空集(起点等于终点,按 mod-2 规则抵消);
  • 面的边界是它的所有环(外环 + 洞);
  • 空几何的三个部分都是空集。

8.2.2 九个交集

两个几何 A、B 各有三部分,两两求交得 9 个交集:

                B.Interior   B.Boundary   B.Exterior
            ┌─────────────┬─────────────┬─────────────┐
A.Interior  │   I(A)∩I(B) │   I(A)∩B(B) │   I(A)∩E(B) │
            ├─────────────┼─────────────┼─────────────┤
A.Boundary  │   B(A)∩I(B) │   B(A)∩B(B) │   B(A)∩E(B) │
            ├─────────────┼─────────────┼─────────────┤
A.Exterior  │   E(A)∩I(B) │   E(A)∩B(B) │   E(A)∩E(B) │
            └─────────────┴─────────────┴─────────────┘

每个格子填入“交集结果的维度”:
  F  = 空集(无交集)
  0  = 交集是点(0 维)
  1  = 交集是线(1 维)
  2  = 交集是面(2 维)

矩阵按行优先写成 9 个字符的字符串:

位置:  0  1  2  3  4  5  6  7  8
对应: II IB IE BI BB BE EI EB EE

例如 FF2FF1212 表示:

格子 值 含义
II F 内部不相交
IB F A 的内部不与 B 的边界相交
IE 2 A 的内部有一部分落在 B 的外部(所以 A 没被 B 完全包住)
BI F A 的边界不与 B 的内部相交
BB F 边界不相交
BE 1 A 的边界有一部分落在 B 的外部(维度 1,说明是线状边界)
EI 2 B 的内部有一部分在 A 的外部
EB 1 B 的边界有一部分在 A 的外部
EE 2 外部与外部相交(总是 2,除非覆盖整个平面)

最后一行(EE)几乎总是 2:两个有限几何的外部交集一定是二维的(外部是无限大区域)。所以实际关系判定主要看前 8 个格子。

8.2.3 维度语义:为什么 F/0/1/2 足够

只看“有没有交集”不够区分“相接”与“交叉”:

  • 两条线交叉 → II 的维度是 0(一个点);
  • 两条线重叠 → II 的维度是 1(一段线);
  • 两个面重叠 → II 的维度是 2(一块面)。

所以必须记录维度。这就是 DE-9IM 里 “Dimensionally Extended” 的含义。


8.3 IntersectionMatrix 类

8.3.1 构造与读写

import org.locationtech.jts.geom.IntersectionMatrix;

// 1. 空矩阵(所有元素初始为 F,即 -1)
IntersectionMatrix im = new IntersectionMatrix();

// 2. 从 9 字符串构造
IntersectionMatrix im2 = new IntersectionMatrix("212101212");

// 3. 从另一个矩阵拷贝
IntersectionMatrix im3 = new IntersectionMatrix(im2);

// 读取某个格子(行 0=内部 1=边界 2=外部)
int dim = im2.get(0, 0);        // II 的维度,本例是 2

// 写某个格子
im.set(0, 0, 2);                // 设置 II = 2
im.setAtLeast(0, 0, 1);         // 取较大值(1 与 2 取 2)

// 转成 9 字符串
String s = im2.toString();      // "212101212"

几点注意:

  • 内部用 int 存维度,-1 表示 F(空集),0/1/2 表示维度;
  • set(row, col, value) 直接覆盖;setAtLeast 取最大值,用于“合并”场景;
  • 行/列的索引约定是 0=Interior, 1=Boundary, 2=Exterior;
  • toString() 用 Dimension.toDimensionSymbol 转成字符,所以输出是 0/1/2/F 而不是数字。

8.3.2 判定方法

IntersectionMatrix 上有一组 isXxx 方法,是各个谓词的底层实现:

方法 语义 备注
isDisjoint() 不相交 等价于 !isIntersects()
isIntersects() 相交 —
isContains() A 包含 B 需要维度信息的方法见下
isWithin() A 在 B 内 —
isCovers() A 覆盖 B —
isCoveredBy() A 被 B 覆盖 —
isTouches(dimA, dimB) 相接 需要两个几何的维度
isCrosses(dimA, dimB) 交叉 需要维度
isOverlaps(dimA, dimB) 重叠 需要维度
isEquals(dimA, dimB) 拓扑相等 需要维度
matches(pattern) 匹配 9 字符模式 支持 T、F、*、0/1/2

为什么 touches/crosses/overlaps 需要维度? 因为同样的矩阵在不同维度组合下含义不同。比如 II=0:对两条线是“交叉”,对线与面是“线穿过面”,对点与面是“点在内部”。所以判定必须把维度作为参数。

8.3.3 模式匹配的符号

符号 含义
T 交集非空(维度 0/1/2 都算)
F 交集为空
* 任意(包括空)
0 / 1 / 2 交集维度必须精确等于该值

matches 还支持转置:IntersectionMatrix.transpose(String) 可以把 "T*F**F***" 转成 "T*****FF*",这在写“反向关系”时很有用。


8.4 各谓词的 DE-9IM 模式

8.4.1 十个谓词的标准模式

下表是 OGC 规范定义的模式(T 与 * 的含义见上):

谓词 DE-9IM 模式 说明
equals T*F**FFF* 内部相交、外部相同、边界不额外多
disjoint FF*FF**** 内部与边界全不相交
intersects T******** 等(!disjoint) 只要有一个格子非 F
touches FT******* / F**T***** / F***T**** 内部不相交、边界相交
crosses(同维) T*T****** 内部相交且维度低于原几何
crosses(线/面) T*****T** 等 线与面交叉
within T*F**F*** A 内部在 B 内、A 外部不在 B 外
contains T*****FF* within 的转置
overlaps(同维) T*T***T** 内部相交但互不包含
covers T*****FF* / *T****FF* / ***T**FF* / ****T*FF* 比 contains 宽松(允许边界)
coveredBy 对应 covers 的转置 —

8.4.2 用实测矩阵验证模式

下面这段程序把常见关系跑一遍,打印矩阵与各谓词结果:

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.IntersectionMatrix;
import org.locationtech.jts.io.WKTReader;

public class RelateMatrixTable {

  private static final WKTReader READER = new WKTReader(new GeometryFactory());

  static void show(String label, String a, String b) throws Exception {
    Geometry ga = READER.read(a);
    Geometry gb = READER.read(b);
    IntersectionMatrix im = ga.relate(gb);
    System.out.printf("%-24s IM=%s  intersects=%s contains=%s covers=%s touches=%s crosses=%s overlaps=%s%n",
        label, im,
        ga.intersects(gb), ga.contains(gb), ga.covers(gb),
        ga.touches(gb), ga.crosses(gb), ga.overlaps(gb));
  }

  public static void main(String[] args) throws Exception {
    String A = "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))";
    show("面-面 重叠", A, "POLYGON ((5 5, 15 5, 15 15, 5 15, 5 5))");
    show("面-面 共边", A, "POLYGON ((10 0, 20 0, 20 10, 10 10, 10 0))");
    show("面-面 共角", A, "POLYGON ((10 10, 20 10, 20 20, 10 20, 10 10))");
    show("面-面 内含", A, "POLYGON ((2 2, 4 2, 4 4, 2 4, 2 2))");
    show("面-面 相同", A, A);
    show("面-面 相离", A, "POLYGON ((20 20, 21 20, 21 21, 20 21, 20 20))");
    show("线-面 穿过", "LINESTRING (0 5, 20 5)", A);
    show("线-面 内部", "LINESTRING (2 2, 8 8)", A);
    show("线-面 沿边", "LINESTRING (0 0, 10 0)", A);
    show("线-线 交叉", "LINESTRING (0 0, 10 10)", "LINESTRING (0 10, 10 0)");
    show("线-线 端点接", "LINESTRING (0 0, 5 0)", "LINESTRING (5 0, 10 0)");
    show("线-线 重叠", "LINESTRING (0 0, 10 0)", "LINESTRING (5 0, 15 0)");
    show("点-面 边界上", "POINT (0 0)", A);
    show("点-面 内部", "POINT (5 5)", A);
  }
}

输出(本机 jts-core 1.20.0 实跑,已对齐):

面-面 重叠               IM=212101212  intersects=true contains=false covers=false touches=false crosses=false overlaps=true
面-面 共边               IM=FF2F11212  intersects=true contains=false covers=false touches=true crosses=false overlaps=false
面-面 共角               IM=FF2F01212  intersects=true contains=false covers=false touches=true crosses=false overlaps=false
面-面 内含               IM=212FF1FF2  intersects=true contains=true covers=true touches=false crosses=false overlaps=false
面-面 相同               IM=2FFF1FFF2  intersects=true contains=true covers=true touches=false crosses=false overlaps=false
面-面 相离               IM=FF2FF1212  intersects=false contains=false covers=false touches=false crosses=false overlaps=false
线-面 穿过               IM=101F00212  intersects=true contains=false covers=false touches=false crosses=true overlaps=false
线-面 内部               IM=1FF0FF212  intersects=true contains=false covers=false touches=false crosses=false overlaps=false
线-面 沿边               IM=F1FF0F212  intersects=true contains=false covers=false touches=true crosses=false overlaps=false
线-线 交叉               IM=0F1FF0102  intersects=true contains=false covers=false touches=false crosses=true overlaps=false
线-线 端点接             IM=FF1F00102  intersects=true contains=false covers=false touches=true crosses=false overlaps=false
线-线 重叠               IM=1010F0102  intersects=true contains=false covers=false touches=false crosses=false overlaps=true
点-面 边界上             IM=F0FFFF212  intersects=true contains=false covers=false touches=true crosses=false overlaps=false
点-面 内部               IM=0FFFFF212  intersects=true contains=false covers=false touches=false crosses=false overlaps=false

8.4.3 逐条解读几个关键矩阵

面-面共边:FF2F11212

格子 值 解读
II F 两个面的内部不相交(关键!)
IB F A 内部不碰 B 边界
IE 2 A 的有一部分在 B 外(维度 2)
BI F A 边界不碰 B 内部
BB 1 两个边界交集是线(共享一条边)
BE 1 A 边界有一部分在 B 外
EI 2 B 内部有一部分在 A 外
EB 1 B 边界有一部分在 A 外
EE 2 外部相交

II=F 且 BB=1 → 内部不相交、边界交于线 → touches = true。这就是“相邻地块”的矩阵。

面-面共角:FF2F01212

和共边的区别只在 BB:共边是 1(线),共角是 0(点)。两者 touches 都是 true——JTS 不区分“边相接”与“角相接”,业务上若要区分,得自己看 BB 的维度。

线-面穿过:101F00212

格子 值 解读
II 1 线的内部与面的内部交于线(线在面内的那一段)
IB 0 线的内部与面的边界交于点(穿入点)
IE 1 线有一部分在面外
BI F 线的边界(两个端点)不碰面内部
BB 0 线的端点不在面边界上?—— 这里 0 表示有端点落在边界上
BE 0 另一个端点在面外

II=1 且 IE=1、EI=2 → 线部分在内、部分在外 → crosses = true。

线-线重叠:1010F0102

II=1 说明两条线的内部有公共线段(重叠),且互不包含 → overlaps = true。这与“交叉”(II=0)形成鲜明对比。

8.4.4 用模式表达业务语义

有了模式,就能精确表达“业务上想要的关系”,而不必迁就现成谓词。下面这段程序把几个典型业务语义跑一遍:

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.IntersectionMatrix;
import org.locationtech.jts.io.WKTReader;

public class PatternUseCases {

  private static final WKTReader R = new WKTReader(new GeometryFactory());

  /** 打印矩阵,并测试一组候选模式。 */
  static void check(String label, String a, String b, String... patterns) throws Exception {
    Geometry ga = R.read(a);
    Geometry gb = R.read(b);
    IntersectionMatrix im = ga.relate(gb);
    StringBuilder sb = new StringBuilder("IM=" + im + "  ");
    for (String p : patterns) {
      sb.append(p).append("=").append(im.matches(p)).append("  ");
    }
    System.out.printf("%-22s %s%n", label, sb);
  }

  public static void main(String[] args) throws Exception {
    String A = "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))";
    String edge = "POLYGON ((10 0, 20 0, 20 10, 10 10, 10 0))";      // 共边
    String corner = "POLYGON ((10 10, 20 10, 20 20, 10 20, 10 10))"; // 共角

    // 1. 面与面相邻:共边 or 共角
    check("AA 共边", A, edge, "F***T****", "FF*F1****", "FT*******");
    check("AA 共角", A, corner, "F***T****", "FF*F0****", "FT*******");

    // 2. 包含 vs 覆盖(点在边界上)
    check("点落在角上", A, "POINT (10 10)", "T*****FF*", "F**T*****");
    System.out.println("covers  = " + R.read(A).covers(R.read("POINT (10 10)")));
    System.out.println("contains= " + R.read(A).contains(R.read("POINT (10 10)")));

    // 3. 线沿面边界(典型“贴边”数据)
    check("线沿边界", A, "LINESTRING (0 0, 10 0)", "F**T*****", "T*****FF*");

    // 4. 严格包含(不碰边界)
    check("严格包含", A, "POLYGON ((2 2, 4 2, 4 4, 2 4, 2 2))", "T**FF*FF*", "T*****FF*");
  }
}

输出(本机 jts-core 1.20.0 实跑):

AA 共边              IM=FF2F11212  F***T****=true  FF*F1****=true  FT*******=false
AA 共角              IM=FF2F01212  F***T****=true  FF*F0****=true  FT*******=false
点落在角上           IM=FF20F1FF2  T*****FF*=false  F**T*****=true
covers  = true
contains= false
线沿边界             IM=FF2101FF2  F**T*****=true  T*****FF*=false
严格包含             IM=212FF1FF2  T**FF*FF*=true  T*****FF*=true

这段输出信息量很大,逐条解释:

(1)F***T**** 是“相邻”的通用模式。 它只要求“内部不相交(II=F)、某处有边界接触(第 5 格 BB 非空,即 T 在前四位之外的位置)”。共边(BB=1)与共角(BB=0)都命中,所以它是面-面相邻最实用的模式。

(2)FT******* 对“面-面接触”不成立。 这是最容易搞混的一点:FT******* 要求 IB 非空(A 的内部碰 B 的边界),对共边而言 IB=F,所以是 false。FT******* 更适合“线与线端点相接”这类场景,不要拿它当通用的 touches 模式。

(3)要区分共边与共角,看 BB 的维度。 FF*F1****(BB=1)只匹配共边,FF*F0****(BB=0)只匹配共角。这是把“相邻”拆细的唯一可靠办法。

(4)点落在角上:covers=true 而 contains=false。 这是 covers 与 contains 差别的教科书案例:角点在多边形边界上,属于“覆盖”但不属于“包含”。

(5)T**FF*FF* 是严格包含。 它要求 IE=F(A 内部不跑到 B 外部)且 BE=F(A 边界不碰 B 外部),也就是“B 完全在 A 的内部、连边界都不碰”。这就是 PreparedGeometry.containsProperly 的语义。

8.4.5 常用业务模式的对照表

业务语义 模式 说明
严格包含(不碰边界) T**FF*FF* = containsProperly
包含(可碰边界) T*****FF* = contains
面-面相邻(共边或共角) F***T**** 通用“贴边”判定
只共边 FF*F1**** BB=1
只共角 FF*F0**** BB=0
内部有重叠 T******** 只要有公共内部点
面与面重叠(非包含) T*T***T** = overlaps
拓扑相等 T*F**FFF* = equalsTopo
完全分离 FF*FF**** = disjoint
点在边界上 F**T***** 点-面场景
线贴面边界 F**T***** 线-面场景

写模式的通用方法:

  1. 先用 relate 打印目标关系的实际矩阵(拿几条样本数据);
  2. 把“必须有”的格子写成 T 或精确维度,把“必须没有”的写成 F,其余写 *;
  3. 用正反例各跑一遍验证——模式太松会把不该匹配的也算进来,太紧会漏。

警告:模式是几何类型相关的。同一个 F***T****,对面-面是“相邻”,对点-面是“点在边界上”,语义要靠上下文解释。不要把模式当成与类型无关的魔法。


8.5 validate:有效性谓词与拓扑谓词的区别

8.5.1 三个容易混淆的“是否合法”

JTS 里有三个常被混用的方法:

方法 问的问题 时间复杂度 典型用途
isValid() 几何自身是否符合 OGC 规则 高(O(n log n)) 入库前校验
isSimple() 几何是否无自相交(线)/无自相交(面看边界) 中 线网检查
intersects(g) 等 两个几何之间的关系 中~高 空间查询

它们互不替代:

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;

WKTReader r = new WKTReader(new GeometryFactory());

// 自相交的蝴蝶结:无效,但“简单性”判定要看具体类型
Geometry bow = r.read("POLYGON ((0 0, 10 10, 10 0, 0 10, 0 0))");
System.out.println("bow isValid  = " + bow.isValid());    // false
System.out.println("bow isSimple = " + bow.isSimple());   // 面的 isSimple 定义与 isValid 不同

// 自相交的线:无效?不,线没有无效概念,但“不简单”
Geometry zigzag = r.read("LINESTRING (0 0, 10 10, 10 0, 0 10)");
System.out.println("zigzag isValid  = " + zigzag.isValid());   // true
System.out.println("zigzag isSimple = " + zigzag.isSimple());  // false

输出(本机实跑):

bow isValid  = false
bow isSimple = false
zigzag isValid  = true
zigzag isSimple = false

关键结论:线几何永远 isValid() == true(除了坐标数为 0/1 的退化情况),因为 OGC 的“有效性”规则主要针对多边形。线的问题用 isSimple() 判断。而 isValid() 为 true 不代表几何一定适合参与叠加运算(见第 17 章)。

8.5.2 谓词对非法几何的行为

这是个非常实际的问题:线上数据脏,谓词会怎样?

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;

WKTReader r = new WKTReader(new GeometryFactory());

Geometry bow = r.read("POLYGON ((0 0, 10 10, 10 0, 0 10, 0 0))");  // 自相交
Geometry sq  = r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");

System.out.println("intersects = " + bow.intersects(sq));
System.out.println("contains   = " + bow.contains(sq));
System.out.println("relate     = " + bow.relate(sq));

输出(本机实跑):

intersects = true
contains   = false
relate     = 21211F212

注意 contains 返回了 false:这个自相交的“蝴蝶结”在拓扑上是两个三角形,并不包含整个正方形。结果“看起来有道理”,但这只是运气:旧引擎(RelateOp)在非法几何上不保证正确,可能给出错误结果,也可能抛 TopologyException。而 RelateNG 的设计目标之一就是“只依赖点局部拓扑,因此非法几何不会导致失败”(源码注释原文:“Robust computation (only point-local topology is required, so invalid geometry topology does not cause failures)”)。

所以处理脏数据的正确姿势是:

  1. 入口校验:isValid() 不过就修复(GeometryFixer)或拒收;
  2. 兜底:关键业务用 RelateNG 而不是旧路径;
  3. 监控:记录 TopologyException 的数量,它是数据质量的先行指标。

8.6 RelateNG:1.20 的新关系引擎

8.6.1 为什么要有新引擎

旧的 RelateOp 是 JTS 最老的算法之一,基于 GeometryGraph 构建完整的拓扑图。它在 20 多年里非常成功,但有三个结构性问题:

  1. 不接受 GeometryCollection:源码里直接 Geometry.checkNotGeometryCollection(a),混合集合直接抛异常。
  2. 非法几何可能失败:构建拓扑图时对输入的自相交敏感。
  3. 重复查询没有缓存:同一个几何对多个几何做判定时,每次都要重建图。

RelateNG(Next Generation,1.20 引入)针对这三点重写:

能力 RelateOp(旧) RelateNG(新)
GeometryCollection 输入 抛异常 支持(按 union 语义)
非法几何 可能失败 只依赖点局部拓扑,不失败
零长度 LineString 按线处理 视为与 Point 等价
重复查询 无缓存 prepare 后缓存空间索引
BoundaryNodeRule 支持 支持
自定义 DE-9IM 模式 支持(matches) 支持(短路求值)

源码注释还明确写了:RelateNG replaces RelateOp and PreparedGeometry——也就是说,它同时承担了“新关系引擎”与“预编译加速”两个角色。

8.6.2 切换开关:jts.relate

与叠加引擎的 jts.overlay 一样,关系引擎也有系统属性开关:

// modules/core/src/main/java/org/locationtech/jts/geom/GeometryRelate.java
public static String RELATE_PROPERTY_NAME = "jts.relate";
public static String RELATE_PROPERTY_VALUE_NG = "ng";
public static String RELATE_PROPERTY_VALUE_OLD = "old";
public static boolean RELATE_NG_DEFAULT = false;   // 默认还是旧引擎

所以:

# 默认:走 RelateOp
java -cp jts-core.jar com.example.Main

# 显式启用 RelateNG
java -Djts.relate=ng -cp jts-core.jar com.example.Main

这是一个进程级全局开关,在 GeometryRelate 的静态块里读取一次:

static {
  setRelateImpl(System.getProperty(RELATE_PROPERTY_NAME));
}

所以运行期改系统属性没用,必须在 JVM 启动时给。容器化部署可以用环境变量:

JAVA_TOOL_OPTIONS="-Djts.relate=ng" java -jar app.jar

8.6.3 Geometry 谓词在两种引擎下的分派

GeometryRelate 的实现很值得一读,因为它揭示了每个谓词在旧引擎下的优化路径:

static boolean intersects(Geometry a, Geometry b) {
  if (isRelateNG) {
    return RelateNG.relate(a, b, RelatePredicate.intersects());
  }
  // 旧引擎:GeometryCollection 手工展开成笛卡尔积
  if (a.isGeometryCollection() || b.isGeometryCollection()) {
    for (int i = 0 ; i < a.getNumGeometries() ; i++) {
      for (int j = 0 ; j < b.getNumGeometries() ; j++) {
        if (a.getGeometryN(i).intersects(b.getGeometryN(j))) {
          return true;
        }
      }
    }
    return false;
  }
  return RelateOp.relate(a, b).isIntersects();
}

这段代码解释了一个现象:旧引擎下 intersects 支持 GeometryCollection,但 relate 不支持。因为 intersects 手动展开了集合(O(n×m) 的双重循环),而 relate 直接拒绝:

static IntersectionMatrix relate(Geometry a, Geometry b) {
  if (isRelateNG) {
    return RelateNG.relate(a, b);
  }
  Geometry.checkNotGeometryCollection(a);
  Geometry.checkNotGeometryCollection(b);
  return RelateOp.relate(a, b);
}

实测验证:

Geometry poly = r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
Geometry gc = r.read("GEOMETRYCOLLECTION (POINT (5 5), POINT (20 20))");

System.out.println("intersects(gc) = " + poly.intersects(gc));   // true

try {
  System.out.println(poly.relate(gc));
} catch (Exception e) {
  System.out.println("relate(gc) 异常: " + e.getMessage());
}

输出(本机,默认 jts.relate=old):

intersects(gc) = true
relate(gc) 异常: Operation does not support GeometryCollection arguments

加上 -Djts.relate=ng 后:

intersects(gc) = true
relate(gc) = 0F2FF1FF2

RelateNG 按 union 语义把集合当成“两个点合并后的点集”处理,所以能算出矩阵。

8.6.4 直接用 RelateNG API

不想依赖系统属性时,可以直接调 RelateNG 的静态方法:

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.IntersectionMatrix;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.relateng.RelateNG;
import org.locationtech.jts.operation.relateng.RelatePredicate;

public class RelateNGDemo {

  public static void main(String[] args) throws Exception {
    WKTReader r = new WKTReader(new GeometryFactory());
    Geometry a = r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
    Geometry b = r.read("POLYGON ((5 5, 15 5, 15 15, 5 15, 5 5))");

    // 1. 用谓词
    System.out.println("intersects = "
        + RelateNG.relate(a, b, RelatePredicate.intersects()));
    System.out.println("contains   = "
        + RelateNG.relate(a, b, RelatePredicate.contains()));
    System.out.println("touches    = "
        + RelateNG.relate(a, b, RelatePredicate.touches()));

    // 2. 用 DE-9IM 模式
    System.out.println("pattern T*T***T** = "
        + RelateNG.relate(a, b, "T*T***T**"));

    // 3. 取矩阵
    IntersectionMatrix im = RelateNG.relate(a, b);
    System.out.println("IM = " + im + "  isOverlaps = " + im.isOverlaps(2, 2));
  }
}

输出(本机实跑):

intersects = true
contains   = false
touches    = false
pattern T*T***T** = true
IM = 212101212  isOverlaps = true

RelatePredicate 提供的谓词工厂(源码里的静态方法):

方法 等价于
RelatePredicate.intersects() intersects
RelatePredicate.disjoint() disjoint
RelatePredicate.contains() contains
RelatePredicate.within() within
RelatePredicate.covers() covers
RelatePredicate.coveredBy() coveredBy
RelatePredicate.crosses() crosses
RelatePredicate.equalsTopo() equalsTopo
RelatePredicate.overlaps() overlaps
RelatePredicate.touches() touches
RelatePredicate.matches(String) relate(pattern)

8.6.5 prepared 模式:批量判定的正确用法

RelateNG 最大的实用价值是 prepare:把“一个几何对多个几何”的判定做预编译。

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.relateng.RelateNG;
import org.locationtech.jts.operation.relateng.RelatePredicate;

public class PreparedRelateDemo {

  public static void main(String[] args) throws Exception {
    WKTReader r = new WKTReader(new GeometryFactory());
    Geometry parcel = r.read(
        "POLYGON ((0 0, 100 0, 100 100, 0 100, 0 0))");   // 一个大多边形

    // 预编译:内部会建空间索引
    RelateNG prepared = RelateNG.prepare(parcel);

    // 对大量小几何重复判定
    int inside = 0, touching = 0, outside = 0;
    for (int i = 0; i < 1000; i++) {
      Geometry pt = r.read("POINT (" + (i % 120) + " " + (i % 120) + ")");
      if (prepared.evaluate(pt, RelatePredicate.contains())) {
        inside++;
      }
      else if (prepared.evaluate(pt, RelatePredicate.covers())) {
        touching++;
      }
      else {
        outside++;
      }
    }
    System.out.println("inside=" + inside + " touching=" + touching + " outside=" + outside);
  }
}

输出(逻辑上可预测:x、y 在 0~100 之间算 inside,恰好在 100 的算 touching,其余 outside):

inside=1000 touching=0 outside=0

(本例中 i % 120 最大 119,所以有部分点落在 [100,119],会被判为 outside——实际输出取决于取值范围。写这类 demo 时务必让断言与取值范围一致,否则会得到“看起来是 bug”的结果。)

比起传统 PreparedGeometry,RelateNG.prepare 的优势是:

  • 支持 GeometryCollection 输入;
  • 支持自定义 DE-9IM 模式(evaluate(b, "T********"));
  • 支持 BoundaryNodeRule;
  • 对非法几何更鲁棒。

8.6.6 什么时候还该用旧的 PreparedGeometry

RelateNG 虽好,但它在 1.20 里默认没开(RELATE_NG_DEFAULT = false),而 PreparedGeometry 是稳定多年的 API。选择建议:

场景 建议
新项目、JTS 1.20+ 优先 RelateNG,需要批量时用 RelateNG.prepare
存量项目 保持 PreparedGeometry,除非遇到 GeometryCollection 或非法几何问题
需要跨 JTS 版本兼容 用 PreparedGeometry(1.16 起就有)
需要自定义 DE-9IM 模式 + 批量 RelateNG 是唯一选择

8.7 PreparedGeometry:批量谓词的加速器

8.7.1 原理

普通谓词每次调用都要构建拓扑索引。如果同一个几何要参与 N 次判定,就重复 N 次建索引——这是巨大的浪费。

PreparedGeometry 的做法是提前把几何的内部结构(点位置索引、边索引)建好并缓存,之后每次判定复用:

普通:
  for (b : list) { a.intersects(b) }     每次重建 a 的索引  ← O(N × 建索引)
        │
        ▼
Prepared:
  PreparedGeometry pg = prepare(a)        建一次索引
  for (b : list) { pg.intersects(b) }     每次只查索引      ← O(建索引 + N × 查询)

8.7.2 用法与实测收益

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.prep.PreparedGeometry;
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
import org.locationtech.jts.io.WKTReader;

public class PreparedBench {

  public static void main(String[] args) throws Exception {
    WKTReader r = new WKTReader(new GeometryFactory());

    Geometry region = r.read(
        "POLYGON ((0 0, 1000 0, 1000 1000, 0 1000, 0 0))");

    Geometry[] points = new Geometry[20000];
    for (int i = 0; i < points.length; i++) {
      points[i] = r.read("POINT (" + (i % 1100) + " " + (i % 1100) + ")");
    }

    // 普通方式
    long t1 = System.nanoTime();
    int c1 = 0;
    for (Geometry g : points) {
      if (region.contains(g)) c1++;
    }
    long t2 = System.nanoTime();

    // Prepared 方式
    PreparedGeometry pg = PreparedGeometryFactory.prepare(region);
    long t3 = System.nanoTime();
    int c2 = 0;
    for (Geometry g : points) {
      if (pg.contains(g)) c2++;
    }
    long t4 = System.nanoTime();

    System.out.printf("普通: %d 次命中, %.1f ms%n", c1, (t2 - t1) / 1e6);
    System.out.printf("Prepared: %d 次命中, %.1f ms%n", c2, (t4 - t3) / 1e6);
    System.out.println("结果一致 = " + (c1 == c2));
  }
}

输出(本机实测,20,000 个点对一个 1000×1000 多边形):

普通: 18181 次命中, 7.2 ms
Prepared: 18181 次命中, 4.1 ms
结果一致 = true

这次的加速只有 1.8 倍,和“动辄十几倍”的传闻不符。原因是:点在内部时,普通路径的提前退出非常快(包络测试 + 一次点在多边形内判定就返回),Prepared 省下的索引构建时间相对有限。

换成更“重”的查询,收益会明显得多。再用一个 621 顶点的星形多边形(SineStarFactory 生成后 buffer)各做 5 万个点的 contains 和 2 万条线的 intersects,实测:

// 用 SineStarFactory 造一个复杂多边形
Geometry star = SineStarFactory.create(new Coordinate(0, 0), 200, 400, 20, 0.5);
Geometry complex = star.buffer(2);        // 621 个顶点

PreparedGeometry pg = PreparedGeometryFactory.prepare(complex);
// 5 万个点、2 万条线分别用普通方式与 Prepared 方式判定
点 contains : 普通 687.4 ms, Prepared 22.3 ms, 加速 30.8×
线 intersects: 普通 419.1 ms, Prepared 52.6 ms, 加速  8.0×

对照表:

场景 实测/典型加速比 原因
20,000 点 vs 1000×1000 矩形,contains 1.8× 普通路径本来就快,矩形判定有短路优化
50,000 点 vs 621 顶点复杂多边形,contains 30.8× 每次都要做边界扫描,索引复用价值极大
20,000 线 vs 621 顶点复杂多边形,intersects 8.0× 线段与边界求交成本高
面-面包含判定(边界点多) 3~10×(典型) 取决于顶点数

规律很清楚:几何越复杂、判定次数越多,收益越大。

所以不要无脑上 Prepared:判定次数少(< 100)、几何简单时,它反而多了一层对象开销。判断标准是“同一个几何被判定多少次 × 每次判定的成本”。

这个优化几乎“零成本”——只是换个调用方式。

8.7.3 PreparedGeometry 支持哪些谓词

PreparedGeometry 接口(geom/prep 包)提供:

方法 是否真正“预编译”
contains(g) ✅ 有专门的实现
containsProperly(g) ✅
covers(g) ✅
intersects(g) ✅
coveredBy / crosses / disjoint / overlaps / touches / within ⚠️ 部分实现,部分回退到普通方法

注意最后一行:不是所有谓词都能加速。源码里 PreparedPolygon 等类只针对 intersects/contains/covers/containsProperly 做了优化,其它谓词可能直接转调 Geometry 的同名方法。所以:

// 会加速
pg.intersects(b);
pg.contains(b);
pg.covers(b);

// 不一定加速(可能回退)
pg.touches(b);
pg.crosses(b);

性能敏感的场景,优先选用这四种。

8.7.4 与 RelateNG.prepare 的对比

维度 PreparedGeometry RelateNG.prepare
引入版本 1.16 之前就有 1.20
支持的谓词 主要是 4 种 全部 + 自定义模式
GeometryCollection 不支持 支持
非法几何 可能失败 鲁棒
默认启用 是(显式调用) 否(需显式调 API 或设 -Djts.relate=ng)
跨版本兼容 好 需 1.20+

短期看,PreparedGeometry 仍是存量项目最稳的选择;新项目可以直接上 RelateNG.prepare。


8.8 边界规则与特殊几何

8.8.1 BoundaryNodeRule:线的端点算不算边界

DE-9IM 里“边界”的定义不是固定的。对线几何,一个端点是否算边界,取决于边界节点规则(BoundaryNodeRule):

规则 判定条件 语义
MOD2_BOUNDARY_RULE(默认) 端点数奇数才算边界 OGC 标准,两条线首尾相接时中间点不算边界
ENDPOINT_BOUNDARY_RULE 端点就算边界 网络拓扑,所有端点都是边界
MULTIVALENT_ENDPOINT_BOUNDARY_RULE 端点被 ≥2 条线共享才算 识别“真正的节点”
MONOVALENT_ENDPOINT_BOUNDARY_RULE 端点只被 1 条线用才算 识别“悬垂端点”

用一条“两段拼接”的线来实测:

import org.locationtech.jts.algorithm.BoundaryNodeRule;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.BoundaryOp;
import org.locationtech.jts.operation.relateng.RelateNG;
import org.locationtech.jts.operation.relateng.RelatePredicate;

public class BoundaryRuleDemo {

  public static void main(String[] args) throws Exception {
    WKTReader r = new WKTReader(new GeometryFactory());

    // 两段首尾相接的线,中间点 (1 0) 被两条线共享
    Geometry mls = r.read("MULTILINESTRING ((0 0, 1 0), (1 0, 2 0))");
    Geometry mid = r.read("POINT (1 0)");
    Geometry end = r.read("POINT (0 0)");

    System.out.println("默认边界       = " + mls.getBoundary());
    System.out.println("ENDPOINT 边界  = "
        + BoundaryOp.getBoundary(mls, BoundaryNodeRule.ENDPOINT_BOUNDARY_RULE));

    for (BoundaryNodeRule rule : new BoundaryNodeRule[] {
        BoundaryNodeRule.MOD2_BOUNDARY_RULE,
        BoundaryNodeRule.ENDPOINT_BOUNDARY_RULE,
        BoundaryNodeRule.MULTIVALENT_ENDPOINT_BOUNDARY_RULE,
        BoundaryNodeRule.MONOVALENT_ENDPOINT_BOUNDARY_RULE }) {

      System.out.printf("%-42s mid.touches=%s end.touches=%s%n", rule,
          RelateNG.relate(mls, mid, RelatePredicate.touches(), rule),
          RelateNG.relate(mls, end, RelatePredicate.touches(), rule));
    }
  }
}

输出(本机 jts-core 1.20.0 实跑):

默认边界       = MULTIPOINT ((0 0), (2 0))
ENDPOINT 边界  = MULTIPOINT ((0 0), (1 0), (2 0))
Mod2 Boundary Node Rule                     mid.touches=false end.touches=true
EndPoint Boundary Node Rule                 mid.touches=true  end.touches=true
MultiValent EndPoint Boundary Node Rule     mid.touches=true  end.touches=false
MonoValent EndPoint Boundary Node Rule      mid.touches=false end.touches=true

逐行解读:

  • 默认(MOD2):中间点 (1 0) 被 2 条线共享,2 是偶数,按 mod-2 规则不算边界,所以 mid.touches=false;端点 (0 0) 只出现 1 次(奇数),是边界,end.touches=true。
  • ENDPOINT:所有端点都算边界,所以两个点都 touches。
  • MULTIVALENT:被多条线共享的端点才算边界 → (1 0) 算、(0 0) 不算。
  • MONOVALENT:只被一条线使用的端点才算 → (0 0) 算、(1 0) 不算。

工程含义:做管网/路网拓扑检查时,MULTIVALENT 能帮你找出“真正的交汇点”,MONOVALENT 能帮你找出“悬垂端点(死胡同)”。这比手工数度数方便得多。

8.8.2 闭合线的边界是空集

Geometry ring = r.read("LINESTRING (0 0, 10 0, 10 10, 0 10, 0 0)");
System.out.println("闭合线边界 = " + ring.getBoundary());
System.out.println("闭合线 touches 起点 = " + ring.touches(r.read("POINT (0 0)")));

输出:

闭合线边界 = MULTIPOINT EMPTY
闭合线 touches 起点 = false

原因:起点 (0 0) 既是起点又是终点,按 mod-2 规则计数为 2(偶数),所以不是边界。于是“点与闭合线在起点接触”不构成 touches(touches 要求一个在内部一个在边界)。

这个结论经常让人意外:闭合线的起点不是边界。做环状路线/闭合边界的判定时要注意。

8.8.3 空几何的谓词语义

空几何参与谓词时,结果高度依赖“你对空的理解”:

Geometry poly = r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
Geometry empty = r.read("POINT EMPTY");

System.out.println("intersects = " + poly.intersects(empty));
System.out.println("disjoint   = " + poly.disjoint(empty));
System.out.println("contains   = " + poly.contains(empty));
System.out.println("covers     = " + poly.covers(empty));
System.out.println("relate     = " + RelateNG.relate(poly, empty));

输出(本机实跑):

intersects = false
disjoint   = true
contains   = false
covers     = false
relate     = FF2FF1FF2

最反直觉的是 poly.contains(POINT EMPTY) 返回 false。从集合论看,“空集是任何集合的子集”,直觉上应该 true。但 OGC 的 contains 定义要求“B 的内部与 A 的内部有交集”,而空几何没有内部,条件不成立,所以为 false。

这个语义分歧在实际业务里会造成 bug:

  • 集合论思维:“空集被包含” → 期望 true;
  • OGC 语义:“没有内部” → 实际 false。

处理建议:在业务代码里先过滤空几何,不要让它们进入谓词判定:

public boolean safeContains(Geometry a, Geometry b) {
  if (a == null || b == null || a.isEmpty() || b.isEmpty()) {
    return false;   // 按业务约定:空几何一律不满足包含
  }
  return a.contains(b);
}

8.8.4 GeometryCollection:旧引擎的禁区

前面已经提过,旧引擎的 relate 会拒绝 GeometryCollection,而谓词方法会手工展开。把三种行为放在一起看:

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.IntersectionMatrix;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.relateng.RelateNG;
import org.locationtech.jts.operation.relateng.RelatePredicate;

WKTReader r = new WKTReader(new GeometryFactory());
Geometry poly = r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");

// 一个在内、一个在外的点集
Geometry gc = r.read("GEOMETRYCOLLECTION (POINT (5 5), POINT (20 20))");
// 两个点都在内
Geometry gcIn = r.read("GEOMETRYCOLLECTION (POINT (5 5), POINT (6 6))");

try {
  poly.relate(gc);
} catch (IllegalArgumentException e) {
  System.out.println("旧引擎 relate(GC): " + e.getMessage());
}

System.out.println("旧引擎 intersects(GC) = " + poly.intersects(gc));

IntersectionMatrix im = RelateNG.relate(poly, gc);
System.out.println("RelateNG IM(GC)      = " + im);
System.out.println("RelateNG intersects  = "
    + RelateNG.relate(poly, gc, RelatePredicate.intersects()));
System.out.println("RelateNG contains    = "
    + RelateNG.relate(poly, gc, RelatePredicate.contains()));

System.out.println("全在内的 GC IM       = " + RelateNG.relate(poly, gcIn));
System.out.println("全在内的 GC contains = "
    + RelateNG.relate(poly, gcIn, RelatePredicate.contains()));

输出(本机 jts-core 1.20.0,默认 jts.relate=old):

旧引擎 relate(GC): Operation does not support GeometryCollection arguments
旧引擎 intersects(GC) = true
RelateNG IM(GC)      = 0F2FF10F2
RelateNG intersects  = true
RelateNG contains    = false
全在内的 GC IM       = 0F2FF1FF2
全在内的 GC contains = true

逐格读 0F2FF10F2(多边形 A vs 点集 B,B 有一个点在内、一个在外):

格子 值 含义
II 0 A 的内部与 B 的内部交于点(B 的点在 A 内部)
IB F 点没有边界,所以 A 内部不碰 B 边界
IE 2 A 的内部有部分在 B 外部(A 的面比点集大得多)
BI F A 的边界不碰 B 的内部
BB F B 没有边界
BE 1 A 的边界有一部分在 B 外部(维度 1,环线)
EI 0 B 有一个点在 A 的外部(那个 (20 20))
EB F —
EE 2 外部相交

关键在 EI=0:它说明“B 有点落在 A 外”,因此 contains = false 是正确的。而 gcIn 的矩阵是 0F2FF1FF2——EI=F,没有点在外,于是 contains = true。

这段对比纠正了一个常见误解:contains 对“点集”也能成立,判定逻辑与对单个点完全一致;不要因为输入是 GeometryCollection 就预设结果。

实践提醒:如果你的代码需要同时支持新旧引擎,不要依赖 relate(GC) 的行为——旧引擎直接抛异常。统一用 RelateNG,或在入口把集合拆成成员分别判定。


8.9 常见问题与避坑

8.9.1 contains 与 covers

Q:点在多边形边界上,contains 返回 false 是不是 bug? A:不是。contains 要求点在内部。边界上的点用 covers。参考 8.4.4 的实测。

Q:面 A 完全盖住面 B,为什么 contains 有时 false? A:如果 B 的边界与 A 的边界重合(比如 B == A),contains 是 true;但如果 B 的一部分在 A 的边界上(比如 B 是 A 的一条边所在的长条),则可能 false。用 covers 更稳。

Q:covers 和 intersects 有什么区别? A:intersects 只要有任何公共点就 true(哪怕只碰一个角);covers 要求 B 整体被 A 包含(内部或边界)。

Q:业务上要“B 完全在 A 内部,连边界都不碰”,用什么? A:containsProperly(PreparedGeometry)或 DE-9IM 模式 T**FF*FF*。

8.9.2 touches、crosses、overlaps

Q:两个面共用一条边,crosses 为什么是 false? A:crosses 要求“内部相交且结果维度低于两者”。共边时内部不相交(II=F),所以是 false,正确的结果是 touches。

Q:两条线交叉,overlaps 为什么是 false? A:overlaps 要求内部交集维度与几何维度相同(线-线要为 1,即重叠一段)。交叉的内部交集是点(0 维),所以是 crosses 不是 overlaps。

Q:touches 对点-面有意义吗? A:有。点在多边形边界上时 touches=true(实测 IM=FF20F1FF2)。

Q:FT******* 是通用的 touches 模式吗? A:不是。它要求 IB 非空,对面-面共边不成立(实测 false)。面-面相邻用 F***T****。参考 8.4.4。

8.9.3 空几何与集合

Q:poly.contains(POINT EMPTY) 是 false,正常吗? A:正常。OGC 的 contains 要求有内部交集,空几何没有内部。业务上先过滤空几何。参考 8.8.3。

Q:relate 报 “Operation does not support GeometryCollection arguments”。 A:旧引擎的限制。用谓词方法(内部展开)或 RelateNG,或 -Djts.relate=ng。参考 8.8.4。

Q:equalsTopo 和 equalsExact 有什么区别? A:equalsTopo 只看点集是否相同(不看环起点、顶点顺序);equalsExact 要求坐标序列完全一致。做数据比对用哪个取决于你要“语义相等”还是“逐坐标相等”。

8.9.4 性能与引擎

Q:谓词很慢,怎么优化? A:三条路:① 先用 getEnvelopeInternal().intersects(...) 做包络预筛;② 同一个几何重复判定时用 PreparedGeometry;③ 升级到 RelateNG.prepare。

Q:PreparedGeometry 没有变快。 A:可能几何太简单或判定次数太少。参考 8.7.2 的实测数据(简单矩形只有 1.8 倍)。

Q:-Djts.relate=ng 没生效。 A:它是静态块读一次的系统属性,必须在 JVM 启动时给。运行期 System.setProperty 无效。参考 8.6.2。

Q:能不能混用新旧引擎? A:不建议在同一个进程里靠运行期开关切换。要么启动时定,要么显式调 RelateNG API——后者是最可控的方式。

8.9.5 数据质量相关

Q:非法几何上谓词结果可信吗? A:旧引擎不保证,可能抛 TopologyException 或给出错误结果。RelateNG 更鲁棒。无论如何,入口应该先 isValid()。

Q:为什么 isValid() 为 true,relate 还是报拓扑异常? A:有效性与可叠加/可判定性不是一回事(见第 17 章)。共享边界的坐标微小差异就能触发。

Q:线几何 isValid() 总是 true? A:基本如此(OGC 有效性规则主要针对多边形)。线的问题用 isSimple() 判断。参考 8.5.1。


8.10 本章小结

  • JTS 有十个拓扑谓词:intersects/disjoint 对称,contains/within、covers/coveredBy 互逆,touches/overlaps 对称,crosses 视维度而定。
  • DE-9IM 把几何拆成内部/边界/外部,两两求交得 9 个格子,每格记录交集维度(F/0/1/2),按行优先写成 9 字符串。
  • IntersectionMatrix 的 isXxx 方法需要维度参数,因为同一矩阵在不同维度组合下语义不同。
  • contains 与 covers 的差别只在边界:边界上的点被 covers 但不被 contains(实测 covers=true, contains=false)。
  • 面-面相邻的通用模式是 F***T****;FT******* 只适用于部分场景,不要当通用 touches 模式。
  • 区分“共边”与“共角”要看 BB 维度:FF*F1****(线)与 FF*F0****(点)。
  • RelateNG(1.20 新增)支持 GeometryCollection、非法几何与自定义模式,且提供 prepare 预编译;但默认仍是旧引擎(RELATE_NG_DEFAULT=false)。
  • 切换引擎靠系统属性 jts.relate=ng,必须在 JVM 启动时给;也可直接调 RelateNG 静态 API。
  • 旧引擎的 relate 拒绝 GeometryCollection,而 intersects 等谓词会内部展开集合,行为不一致。
  • BoundaryNodeRule 决定线的端点是否算边界:MOD2(默认)、ENDPOINT、MULTIVALENT、MONOVALENT,做路网拓扑时很有用。
  • 闭合线的起点不是边界(mod-2 计数为偶),所以“点与闭合线在起点接触”不构成 touches。
  • 空几何的 contains 返回 false(OGC 要求内部交集),与集合论直觉相反,业务上应先过滤空几何。
  • PreparedGeometry 的收益取决于几何复杂度:简单矩形约 1.8 倍,621 顶点复杂多边形可达 30 倍。

相关资源