第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***** |
线-面场景 |
写模式的通用方法:
- 先用
relate打印目标关系的实际矩阵(拿几条样本数据); - 把“必须有”的格子写成
T或精确维度,把“必须没有”的写成F,其余写*; - 用正反例各跑一遍验证——模式太松会把不该匹配的也算进来,太紧会漏。
警告:模式是几何类型相关的。同一个 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)”)。
所以处理脏数据的正确姿势是:
- 入口校验:
isValid()不过就修复(GeometryFixer)或拒收; - 兜底:关键业务用
RelateNG而不是旧路径; - 监控:记录
TopologyException的数量,它是数据质量的先行指标。
8.6 RelateNG:1.20 的新关系引擎
8.6.1 为什么要有新引擎
旧的 RelateOp 是 JTS 最老的算法之一,基于 GeometryGraph 构建完整的拓扑图。它在 20 多年里非常成功,但有三个结构性问题:
- 不接受
GeometryCollection:源码里直接Geometry.checkNotGeometryCollection(a),混合集合直接抛异常。 - 非法几何可能失败:构建拓扑图时对输入的自相交敏感。
- 重复查询没有缓存:同一个几何对多个几何做判定时,每次都要重建图。
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 倍。
相关资源
- JTS 官方仓库:https://github.com/locationtech/jts
- JTS Javadoc(
operation.relate):https://locationtech.github.io/jts/javadoc/org/locationtech/jts/operation/relate/package-summary.html - JTS Javadoc(
operation.relateng):https://locationtech.github.io/jts/javadoc/org/locationtech/jts/operation/relateng/package-summary.html - OGC Simple Features 规范(DE-9IM 定义):https://www.ogc.org/standards/sfa
- JTS 版本历史(1.20 引入 RelateNG):https://github.com/locationtech/jts/blob/master/doc/JTS_Version_History.md
- 本系列第 04 章(几何对象模型详解):https://znlgis.github.io/gis/tutorial/jts/第04章-几何对象模型详解/
- 本系列第 07 章(GeoJSON 与 TWKB 数据交换):https://znlgis.github.io/gis/tutorial/jts/第07章-GeoJSON与TWKB数据交换/
- 本系列第 09 章(叠加分析详解):https://znlgis.github.io/gis/tutorial/jts/第09章-叠加分析详解/
- 本系列第 17 章(有效性与 Coverage 质检):https://znlgis.github.io/gis/tutorial/jts/第17章-有效性与Coverage质检/