第04章 - 几何对象模型详解
前三章解决了“JTS 是什么、怎么装、代码怎么组织”。从这一章起真正进入使用层面:几何对象到底长什么样,怎么创建,怎么读,有哪些坑。
几何模型是全书的地基。后面所有章节——谓词、叠加、缓冲、索引、化简——都建立在这一章的概念之上。如果这一章没过关,会出现很多“似懂非懂”的症状:知道 Polygon 由环组成,但说不清环的方向对结果有什么影响;知道 getBoundary() 返回边界,但不知道为什么多边形有洞时返回的是 MultiLineString;知道 equals 是比较,但分不清它和 equalsTopo 的区别。
本章按“从下往上”的顺序展开:先讲坐标与坐标序列(最底层),再讲包络与维度(描述性概念),然后是几何类层次与工厂(创建),最后是空几何、环方向、SRID、精度模型这些容易出错的主题。读完后你应该能够:准确创建任意类型的几何;知道每个几何对象的内部结构;判断两个几何“相等”的准确含义;并避开这一层最常见的十个坑。
4.1 坐标:Coordinate 家族
4.1.1 从二维到 XYZM
JTS 的几何由坐标构成,最小的单位是 Coordinate。1.16 之前只有 Coordinate(X、Y、Z),1.16 起为了支持 M 值与内存优化,拆出了一组类:
| 类 | 存储 | 是否有 Z | 是否有 M | 说明 |
|---|---|---|---|---|
Coordinate |
x, y, z | 有(可为 NaN) | 无 | 旧类,仍兼容 |
CoordinateXY |
x, y | 无 | 无 | 最省内存的二维坐标 |
CoordinateXYZ |
x, y, z | 有 | 无 | 显式三维 |
CoordinateXYM |
x, y, m | 无 | 有 | 带测量值 |
CoordinateXYZM |
x, y, z, m | 有 | 有 | 四维 |
它们都实现同一个 Coordinate 能力集合(通过继承或接口),但不是同一个类。这一点在集合、比较、序列化时会产生实际影响:
Coordinate c1 = new Coordinate(1, 2);
Coordinate c2 = new CoordinateXY(1, 2);
System.out.println(c1.equals(c2)); // false(类型不同,equals 严格)
System.out.println(c1.equals2D(c2)); // true(只比较 X/Y)
4.1.2 关键 API
| 方法 | 作用 | 注意 |
|---|---|---|
getX()/getY() |
读坐标 | —— |
getZ() |
读 Z | 无 Z 时返回 Double.NaN |
getM() |
读 M | 无 M 时返回 Double.NaN |
setX/setY/setZ/setM |
写坐标 | 坐标是可变对象 |
equals2D(Coordinate) |
只比 X/Y | 最常用 |
equals3D(Coordinate) |
比 X/Y/Z | —— |
equals(Coordinate) |
严格比较(含类型) | 慎用 |
distance(Coordinate) |
两点距离 | 2D 欧氏距离 |
compareTo(Coordinate) |
排序 | 先 X 后 Y,字典序 |
copy() |
复制 | 避免共享引用 |
4.1.3 坐标是可变的
这是 JTS 里少数“反直觉”的地方:Coordinate 可变。
Coordinate c = new Coordinate(0, 0);
c.x = 10; // 直接改字段
c.setY(20); // 或通过 setter
Geometry g = factory.createPoint(c);
c.x = 999; // 注意:这不会改变已创建的 Point!
System.out.println(g); // POINT (0 0)
关键点:GeometryFactory.createPoint(Coordinate) 会复制坐标到内部序列,所以之后再改原 Coordinate 不影响几何。但如果你拿到的是 Geometry.getCoordinate() 返回的坐标,那可能是内部引用,改它可能影响几何(取决于底层序列实现)。稳妥做法:要改几何,用 GeometryEditor 或重建;不要依赖 getCoordinate() 的返回值可写。
4.1.4 Z 与 M 的语义
- Z:高程/第三维。JTS 的几何运算基本忽略 Z(buffer、intersection 等都在 2D 平面计算,Z 值可能丢失或按插值处理);
- M:测量值(measure),常用于线性参考(里程、时间戳)。M 同样不参与空间运算,只是携带。
// 带 Z 的 WKT(旧 3D 格式或 EWKT)
Geometry g1 = reader.read("POINT Z (1 2 3)");
System.out.println(g1.getCoordinate().getZ()); // 3.0
// 带 M 的 WKT
Geometry g2 = reader.read("POINT M (1 2 100)");
System.out.println(g2.getCoordinate().getM()); // 100.0
// XYZM
Geometry g3 = reader.read("POINT ZM (1 2 3 100)");
WKT 的 Z/M 语法在 1.19+ 支持较好,早期版本可能只认 POINT Z 或静默丢弃 M。第 6 章会详细讲序列化时的维度处理。
4.1.5 坐标精度与浮点陷阱
double 的精度约 15~17 位有效数字。对经纬度(约 -180~180)来说,这个精度足够;但对“大坐标 + 小距离”的场景(如国家 2000 坐标系的 X 值约 4×10^7,需要 0.001 米精度),浮点误差可能累积。
// 一个经典的浮点陷阱
double x = 0.1 + 0.2;
System.out.println(x == 0.3); // false
// JTS 的应对:① 精度模型(FIXED)② DD 类(双精度双倍)
第 18 章会专门讨论精度模型。这里只需建立意识:坐标是 double,运算有误差,拓扑判断不能依赖精确相等。
4.2 CoordinateSequence:坐标序列
4.2.1 为什么需要序列
Coordinate[] 数组简单直接,但有两个问题:内存不紧凑(每个 Coordinate 是对象,有对象头开销),且不支持按维度动态存储。于是 JTS 抽象出 CoordinateSequence:
CoordinateSequence (interface)
├── getDimension() 2 / 3 / 4
├── getMeasures() 0 / 1
├── size() 坐标个数
├── getX(i) / getY(i) / getZ(i) / getM(i)
├── getCoordinate(i)
├── toCoordinateArray()
└── 工厂:CoordinateSequenceFactory
4.2.2 两种实现的对比
| 实现 | 内部存储 | 内存 | 访问速度 | 适用 |
|---|---|---|---|---|
CoordinateArraySequence |
Coordinate[](对象数组) |
大(每点一个对象) | 中 | 默认;需要灵活 Z/M |
PackedCoordinateSequence |
double[] 或 float[] |
小(连续内存) | 快(缓存友好) | 大数据量、性能敏感 |
一个 10 万顶点的线串,两种实现的内存差距可以到 5~10 倍。
4.2.3 默认工厂
重要事实:new GeometryFactory() 的默认 CoordinateSequenceFactory 是 CoordinateArraySequenceFactory,不是 Packed。很多人以为“JTS 默认用紧凑数组”,这是错的。
// 默认工厂
GeometryFactory defaultGf = new GeometryFactory();
System.out.println(defaultGf.getCoordinateSequenceFactory().getClass().getSimpleName());
// CoordinateArraySequenceFactory
// 显式使用紧凑数组
GeometryFactory packedGf = new GeometryFactory(
new PrecisionModel(),
4326,
PackedCoordinateSequenceFactory.DOUBLE_FACTORY);
什么时候该用 Packed:
- 顶点数超过 1 万、且批量创建几何;
- 内存受限(Android、容器);
- 只读场景(Packed 的写操作支持较弱)。
什么时候不必:
- 小几何(<1000 点);
- 需要频繁读写 Z/M 且类型多样;
- 需要把序列当作
Coordinate[]直接操作。
4.2.4 序列的读取与转换
Geometry g = reader.read("LINESTRING (0 0, 10 10, 20 0)");
CoordinateSequence seq = g.getCoordinates().length > 0
? ((LineString) g).getCoordinateSequence()
: null;
for (int i = 0; i < seq.size(); i++) {
System.out.printf("i=%d x=%.1f y=%.1f%n", i, seq.getX(i), seq.getY(i));
}
// 转成数组(会复制)
Coordinate[] arr = seq.toCoordinateArray();
注意 toCoordinateArray() 是复制,大数据量时不要循环调用。
4.2.5 自定义序列工厂
理论上可以实现自己的 CoordinateSequenceFactory(例如从内存映射文件读坐标),但实践中极少需要。JTS 内部的 CoordinateSequences 工具类提供了序列的复制、反转、滚动等操作,比自己写更安全。
4.3 Envelope:包络矩形
4.3.1 什么是包络
Envelope 是几何的最小外接矩形(MBR, Minimum Bounding Rectangle),由 (minX, maxX, minY, maxY) 四个值定义。它是 JTS 里最常用的加速结构:任何先粗筛后精算的算法都会先用包络快速排除。
maxY ┌──────────────────┐
│ │
│ Polygon │
│ │
minY └──────────────────┘
minX maxX
4.3.2 常用 API
| 方法 | 作用 |
|---|---|
getEnvelopeInternal() |
取包络(返回内部引用) |
getEnvelope() |
取包络(返回几何形式,Polygon) |
isNull() |
空包络判定 |
contains(Envelope) |
包络包含 |
intersects(Envelope) |
包络相交(最快的粗筛) |
expandBy(double) |
扩大 |
expandToInclude(Envelope) |
合并 |
getArea() |
包络面积(不是几何面积!) |
getWidth()/getHeight() |
宽高 |
4.3.3 语义与坑
Geometry g = reader.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
Envelope env = g.getEnvelopeInternal();
System.out.println(env); // Envelope[0.0 : 10.0, 0.0 : 10.0]
System.out.println(env.getArea()); // 100.0(包络面积)
System.out.println(g.getArea()); // 100.0(几何面积,此处恰好相等)
Geometry circle = reader.read("POLYGON ((...))"); // 近似圆
System.out.println(circle.getEnvelopeInternal().getArea()); // 远大于实际面积
三个坑:
- 空几何的包络是“空包络”:
isNull()返回 true,getArea()返回 0,不要以为是(0,0)-(0,0); - 包络相交不代表几何相交:包络只是粗筛,两个对角重叠的三角形包络可能相交但几何相离;
getEnvelope()返回的是新几何(Polygon 形式),getEnvelopeInternal()才返回轻量对象,索引/循环里用后者。
4.3.4 在索引与查询中的作用
STRtree 的查询接口就是基于 Envelope:
STRtree index = new STRtree();
for (Geometry g : geometries) {
index.insert(g.getEnvelopeInternal(), g);
}
List<?> hits = index.query(queryGeo.getEnvelopeInternal());
// hits 只是候选,还要用 intersects/contains 精筛
这就是“包络粗筛 + 精确判断”的经典模式,第 13 章会详细讲。
4.4 几何类层次
4.4.1 完整层次
Geometry (抽象基类)
│ 通用能力:getArea/getLength/getCentroid/getBoundary/getDimension
│ getEnvelopeInternal/intersects/contains/union/buffer
│ getSRID/getFactory/getNumGeometries/getGeometryN
│
├── Point (0 维)
├── LineString (1 维)
│ └── LinearRing (闭合的 LineString)
├── Polygon (2 维,shell + holes)
└── GeometryCollection (集合,维度取决于成员)
├── MultiPoint
├── MultiLineString
└── MultiPolygon
4.4.2 各类型的关键属性
| 类型 | 维度 | 边界 | 内部结构 | 有效条件 |
|---|---|---|---|---|
| Point | 0 | 空 | 1 个坐标(或空) | 坐标非 NaN |
| LineString | 1 | 2 个端点(闭合时为 0 个) | CoordinateSequence |
至少 0 或 ≥2 点 |
| LinearRing | 1 | 空 | 闭合序列 | 0 点或 ≥4 点且首尾相同 |
| Polygon | 2 | 环集合 | shell + holes(LinearRing[]) |
环闭合、不自交、孔在内部 |
| MultiPoint | 0 | 空 | Point[] | 各成员有效 |
| MultiLineString | 1 | 端点集合 | LineString[] | 各成员有效 |
| MultiPolygon | 2 | 环集合 | Polygon[] | 各成员有效+不重叠 |
| GeometryCollection | 不定 | 视成员 | Geometry[] | 无额外约束 |
4.4.3 不能对 GeometryCollection 做的事
这是 JTS 的一个“隐藏规则”:很多操作不接受 GeometryCollection 作为输入。
Geometry gc = reader.read("GEOMETRYCOLLECTION (POINT (0 0), LINESTRING (1 1, 2 2))");
gc.union(somePolygon); // 抛 IllegalArgumentException?
实际行为:Geometry.checkNotGeometryCollection() 会抛出 IllegalArgumentException。但注意,MultiPolygon 等子类不受此限制(它们是特化的集合)。判断方式:
// 错误
if (g instanceof GeometryCollection) { ... } // MultiPolygon 也会命中
// 正确
if (g.isGeometryCollection()) { ... } // 仅纯 GeometryCollection
// 或
if (g.getGeometryType().equals("GeometryCollection")) { ... }
4.4.4 getNumGeometries / getGeometryN 的统一接口
所有几何都实现了集合式访问接口,这让遍历代码可以统一:
// 对 Point 返回 1,对 MultiPolygon 返回成员数
for (int i = 0; i < g.getNumGeometries(); i++) {
Geometry part = g.getGeometryN(i);
System.out.println(part.getGeometryType() + " area=" + part.getArea());
}
这是写通用处理代码(统计、转换、序列化)的推荐方式,比 instanceof 分支更稳。
4.5 GeometryFactory:几何创建
4.5.1 构造工厂
// 1. 默认:FLOATING 精度、SRID=0、CoordinateArraySequence
GeometryFactory gf1 = new GeometryFactory();
// 2. 指定 SRID
GeometryFactory gf2 = new GeometryFactory(new PrecisionModel(), 4326);
// 3. 指定 SRID + 序列工厂
GeometryFactory gf3 = new GeometryFactory(
new PrecisionModel(), 4326, PackedCoordinateSequenceFactory.DOUBLE_FACTORY);
// 4. 指定精度模型
GeometryFactory gf4 = new GeometryFactory(new PrecisionModel(1000)); // 保留 3 位小数
4.5.2 创建方法全表
| 方法 | 说明 |
|---|---|
createPoint() / createPoint(Coordinate) / createPoint(CoordinateSequence) |
创建点(空点/坐标点) |
createLineString() / (Coordinate[]) / (CoordinateSequence) |
创建线 |
createLinearRing() / (Coordinate[]) / (CoordinateSequence) |
创建环(自动校验闭合) |
createPolygon() / (Coordinate[]) / (LinearRing shell) / (LinearRing, LinearRing[]) / (CoordinateSequence) |
创建面 |
createMultiPoint(Point[]) / (Coordinate[]) / (CoordinateSequence) / createMultiPointFromCoords |
创建多点 |
createMultiLineString(LineString[]) |
创建多线 |
createMultiPolygon(Polygon[]) |
创建多面 |
createGeometryCollection(Geometry[]) |
创建几何集合 |
createEmpty(int dimension) |
创建指定维度的空几何 |
createGeometry(Geometry) |
复制并改用当前工厂 |
4.5.3 创建时发生了什么
以 createPolygon(Coordinate[]) 为例,内部流程:
createPolygon(Coordinate[] coordinates)
└── createLinearRing(coordinates) ← 校验:至少 4 点、首尾相同
└── createLineString(sequence)
└── CoordinateSequenceFactory.create(coordinates)
└── new Polygon(shell, holes, factory)
└── 计算包络、校验基本约束
两个重要行为:
- 不自动闭合:
createPolygon(Coordinate[])要求传入的坐标已经闭合(首尾相同),否则抛IllegalArgumentException; - 不校验有效性:创建时只做基本结构检查(点数、闭合),不会检查自相交、孔在外部等拓扑问题。有效性要靠
IsValidOp(第 17 章)。
// 正确:闭合
Polygon ok = gf.createPolygon(new Coordinate[] {
new Coordinate(0, 0), new Coordinate(10, 0),
new Coordinate(10, 10), new Coordinate(0, 10),
new Coordinate(0, 0) // 必须回到起点
});
// 错误:未闭合 → IllegalArgumentException
try {
gf.createPolygon(new Coordinate[] {
new Coordinate(0, 0), new Coordinate(10, 0),
new Coordinate(10, 10), new Coordinate(0, 10)
});
} catch (IllegalArgumentException e) {
System.out.println("环未闭合: " + e.getMessage());
}
4.5.4 空几何的创建
Point emptyPoint = gf.createPoint(); // POINT EMPTY
LineString emptyLine = gf.createLineString(); // LINESTRING EMPTY
Polygon emptyPolygon = gf.createPolygon(); // POLYGON EMPTY
Geometry emptyOfDim2 = gf.createEmpty(2); // POLYGON EMPTY
空几何不是 null,它有类型、有 SRID、有包络(空包络)。很多 bug 来自“把空几何当 null 判断”。
4.5.5 工厂与运算结果
几何运算的结果会沿用输入的工厂:
GeometryFactory gf4326 = new GeometryFactory(new PrecisionModel(), 4326);
Geometry a = gf4326.createPoint(new Coordinate(0, 0));
Geometry b = gf4326.createPoint(new Coordinate(1, 1));
Geometry union = a.union(b);
System.out.println(union.getSRID()); // 4326(继承自 a)
如果两个输入的工厂不同(SRID 不同),JTS 不会报错,结果取第一个输入的工厂。跨 SRID 运算在 JTS 里是允许的,但结果是错的——这是第 19 章要强调的工程问题。
4.6 维度、边界与位置
4.6.1 Dimension
Dimension 是一个枚举风格的类,取值:
| 常量 | 值 | 含义 |
|---|---|---|
P |
0 | 点 |
L |
1 | 线 |
A |
2 | 面 |
FALSE |
-1 | 空 |
System.out.println(g.getDimension()); // 0/1/2
System.out.println(g.getBoundaryDimension()); // 边界的维度
System.out.println(g.hasDimension(1)); // 1.20+ 新增
维度在 DE-9IM 判断里是核心(交集是几维,决定了关系的语义),第 8 章会反复用到。
4.6.2 Boundary
Point p = gf.createPoint(new Coordinate(0, 0));
System.out.println(p.getBoundary()); // GEOMETRYCOLLECTION EMPTY(点无边界的“几何”)
LineString line = (LineString) reader.read("LINESTRING (0 0, 10 0)");
System.out.println(line.getBoundary()); // MULTIPOINT ((0 0), (10 0))
Polygon poly = (Polygon) reader.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
System.out.println(poly.getBoundary()); // LINEARRING (0 0, 10 0, 10 10, 0 10, 0 0)
// 有洞的多边形
Polygon withHole = (Polygon) reader.read(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))");
System.out.println(withHole.getBoundary()); // MULTILINESTRING (...)
关键事实:
- 点的边界是空;
- 闭合线(首尾相同)的边界是空(Mod-2 边界规则);
- 多边形无洞时边界是
LinearRing(1 条线); - 多边形有洞时边界是
MultiLineString(多条线); - GeometryCollection 的
getBoundary()直接抛异常。
4.6.3 Location(位置)
Location 表示一个点在几何中的位置:INTERIOR、BOUNDARY、EXTERIOR。它是判断算法的内部表示,业务代码很少直接用,但理解它有助于理解谓词语义:
import org.locationtech.jts.algorithm.PointLocator;
import org.locationtech.jts.geom.Location;
PointLocator locator = new PointLocator();
Location loc = locator.locate(new Coordinate(0, 0), polygon);
System.out.println(loc == Location.BOUNDARY ? "边界上" : "不是边界");
4.7 空几何与生命周期
4.7.1 空几何不是 null
Geometry empty = gf.createPolygon(); // 非 null
System.out.println(empty.isEmpty()); // true
System.out.println(empty.getNumPoints()); // 0
System.out.println(empty.getGeometryType()); // Polygon
System.out.println(empty.getEnvelopeInternal().isNull()); // true
判断空要用 isEmpty(),不要用 == null。反过来,如果下游返回 null(例如某些工具类),也要先判 null 再判 empty。
4.7.2 空几何在运算中的行为
| 运算 | 行为 |
|---|---|
intersects |
与任何几何都返回 false |
contains |
返回 false |
union(empty, x)``` |
返回 x 的副本 |
intersection(empty, x) |
返回空 |
distance(empty, x) |
返回 0(JTS 的实现细节,注意!) |
buffer(empty) |
返回空 |
distance 返回 0 这一点很容易误导——数学上“空到任何东西的距离”应是未定义或无穷,JTS 实现成 0,使用时要注意先判空。
4.7.3 几何对象的一生
创建(工厂) → 只读使用(谓词/运算/序列化) → 可选:编辑(GeometryEditor 产生新对象)
→ 丢弃(GC 回收)
建议:不要长期持有大量几何对象而不释放引用;批量处理要分批。
4.8 环方向与规范化
4.8.1 环方向的约定
OGC 建议(不是强制):多边形外环逆时针、内环顺时针。JTS 的实际情况:
- 创建时不强制环方向;
Polygon.normalize()会把外环规整为顺时针、内环为逆时针(JTS 的 normalize 与 OGC 建议方向相反,以源码为准);- 大部分运算对环方向不敏感(拓扑判断会自己处理),但序列化、比较、渲染可能受影响。
Polygon p = (Polygon) reader.read(
"POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0))"); // 逆时针?
System.out.println(p.normalize()); // 规整后的 WKT
4.8.2 isCCW / 方向判断
// 判断环的方向(1.19+ 提供了 Orientation.isCCW)
import org.locationtech.jts.algorithm.Orientation;
boolean ccw = Orientation.isCCW(polygon.getExteriorRing().getCoordinates());
4.8.3 为什么方向重要
| 场景 | 影响 |
|---|---|
| 导出为 Shapefile | Shapefile 规范要求外环顺时针、内环逆时针,方向错会导致部分软件识别失败 |
| 导出为 GeoJSON | RFC 7946 建议外环逆时针(右手定则) |
| 渲染 | 某些渲染引擎用方向做填充规则(nonzero) |
| 相等比较 | equalsExact 对方向敏感,equalsTopo 不敏感 |
建议:对外输出前统一 normalize(),或按目标规范显式调整方向。
4.8.4 reverse 与 normalize
Geometry g = reader.read("LINESTRING (0 0, 10 10)");
Geometry r = g.reverse();
System.out.println(r); // LINESTRING (10 10, 0 0)
Geometry n = g.norm(); // 等价于 copy + normalize
reverse() 返回新对象,顺序完全颠倒;normalize() 是原地修改(返回 void 或 this,视版本),会规整环方向与起点。批量输出前对 copy() 再 normalize,避免改到原对象。
4.9 Polygon 结构细节
4.9.1 shell 与 holes
Polygon 是 JTS 里结构最复杂的几何,内部由两部分组成:
Polygon
├── shell : LinearRing (外环,必须存在,即使为空)
└── holes : LinearRing[] (内环数组,可为空)
对应的访问 API:
| 方法 | 返回 | 说明 |
|---|---|---|
getExteriorRing() |
LinearRing |
外环 |
getNumInteriorRing() |
int |
内环数量 |
getInteriorRingN(int) |
LinearRing |
第 n 个内环 |
getNumPoints() |
int |
所有环的顶点总数 |
getCoordinates() |
Coordinate[] |
所有环的坐标拼接(不区分环) |
getExteriorRing().getCoordinates() |
外环坐标 | 只取外环 |
注意一个陷阱:getCoordinates() 返回的是所有环坐标的拼接,你无法从结果里区分“哪一段属于外环、哪一段属于内环”。要按环处理必须用 getExteriorRing() 与 getInteriorRingN() 逐个遍历。
Polygon poly = (Polygon) reader.read(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), " +
" (2 2, 4 2, 4 4, 2 4, 2 2), " +
" (6 6, 8 6, 8 8, 6 8, 6 6))");
System.out.println("外环点数: " + poly.getExteriorRing().getNumPoints()); // 5
System.out.println("内环数量: " + poly.getNumInteriorRing()); // 2
System.out.println("总点数: " + poly.getNumPoints()); // 15
System.out.println("面积: " + poly.getArea()); // 100 - 4 - 4 = 92
// 遍历所有环
for (int i = 0; i < poly.getNumInteriorRing(); i++) {
LinearRing hole = poly.getInteriorRingN(i);
System.out.println("内环 " + i + " 面积: " + hole.getArea());
}
4.9.2 孔洞的有效性约束
一个带孔多边形要“有效”,必须满足(第 17 章会系统讲):
- 外环与每个内环都闭合;
- 环之间不相交(孔不能跨过外环边界);
- 孔不能包含另一个孔(除非形成“岛”,那需要 MultiPolygon);
- 孔必须在多边形内部,而不是外部;
- 环自身不自相交。
// 无效:孔在外部
Geometry invalid = reader.read(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (20 20, 22 20, 22 22, 20 22, 20 20))");
IsValidOp validOp = new IsValidOp(invalid);
System.out.println(validOp.isValid()); // false
System.out.println(validOp.getValidationError()); // 错误信息
创建时不校验这一点再强调一次:GeometryFactory.createPolygon 只检查“环闭合”和“点数足够”,不做拓扑检查。所以无效几何可以顺利创建、顺利 WKT 化、顺利入库,直到某次运算才爆炸。
4.9.3 环的几何意义
- 外环定义多边形的边界,环的方向在 JTS 内部不影响面积计算(面积用绝对值或在计算时处理符号);
- 内环表示“挖空”,
getArea()会减去内环面积; - 一个多边形可以没有内环,但不能没有外环(空多边形除外)。
// 面积计算示意
Polygon rect = (Polygon) reader.read("POLYGON ((0 0, 10 0, 10 5, 0 5, 0 0))");
System.out.println(rect.getArea()); // 50.0
// 用 buffer(-x) 做“内缩”时,孔可能被消除,环数量会变化
Polygon shrunk = (Polygon) rect.buffer(-1);
System.out.println(shrunk.getNumInteriorRing());
4.9.4 从环构造多边形
LinearRing shell = gf.createLinearRing(new Coordinate[] {
new Coordinate(0, 0), new Coordinate(10, 0),
new Coordinate(10, 10), new Coordinate(0, 10),
new Coordinate(0, 0)
});
LinearRing hole = gf.createLinearRing(new Coordinate[] {
new Coordinate(2, 2), new Coordinate(4, 2),
new Coordinate(4, 4), new Coordinate(2, 4),
new Coordinate(2, 2)
});
Polygon withHole = gf.createPolygon(shell, new LinearRing[] { hole });
System.out.println(withHole.getArea()); // 100 - 4 = 96
如果只需要外壳,可以传 null 或空数组作为 holes。传 null 时 JTS 内部会转成空数组,但显式传空数组更清晰。
4.10 GeometryCollection 与 Multi* 的差异
4.10.1 语义差异
| 维度 | GeometryCollection | Multi* |
|---|---|---|
| 成员类型 | 任意(可混合点线面) | 单一类型(MultiPoint 只能装 Point) |
| 运算支持 | 多数运算不支持(先展开) | 完全支持 |
| 有效性 | 无额外约束 | 成员之间不能重叠(MultiPolygon) |
| 用途 | 临时容器、异构结果承载 | 表示同类型的多部件要素 |
// 合法:MultiPolygon 两个不相交的面
Geometry mp = reader.read("MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)), ((2 2, 3 2, 3 3, 2 3, 2 2)))");
// 有效性问题:MultiPolygon 成员重叠
Geometry overlap = reader.read("MULTIPOLYGON (((0 0, 2 0, 2 2, 0 2, 0 0)), ((1 1, 3 1, 3 3, 1 3, 1 1)))");
new IsValidOp(overlap).isValid(); // false(成员重叠)
4.10.2 类型判断的正确姿势
// 不要用 instanceof 判断“是不是集合”
if (g instanceof GeometryCollection) { ... } // MultiPolygon 也会命中
// 用 isGeometryCollection()
if (g.isGeometryCollection()) { ... } // 仅纯 GeometryCollection
// 或者用几何类型字符串
switch (g.getGeometryType()) {
case "Point": ...
case "MultiPolygon": ...
case "GeometryCollection": ...
default: ...
}
4.10.3 展开与打平
把一个 GeometryCollection 拆成原子几何(最底层不可再分的 Point/LineString/Polygon):
import org.locationtech.jts.geom.GeometryCollectionIterator;
// 方式一:递归遍历
for (java.util.Iterator<?> it = new GeometryCollectionIterator(gc); it.hasNext(); ) {
Geometry part = (Geometry) it.next();
if (!(part instanceof GeometryCollection)) {
System.out.println(part.getGeometryType());
}
}
// 方式二:用 GeometryExtracter 按类型提取
import org.locationtech.jts.geom.util.LineStringExtracter;
Geometry lines = LineStringExtracter.getGeometry(gc);
第 5 章会系统讲 GeometryExtracter、LineStringExtracter、PolygonExtracter 这类工具。
4.11 SRID、用户数据与元信息
4.11.1 SRID 的传递
GeometryFactory gf4326 = new GeometryFactory(new PrecisionModel(), 4326);
Geometry p = gf4326.createPoint(new Coordinate(116.4, 39.9));
System.out.println(p.getSRID()); // 4326
// 运算结果继承输入工厂
Geometry buffered = p.buffer(0.01);
System.out.println(buffered.getSRID()); // 4326
两个几何 SRID 不同时,JTS 不报错,结果取第一个的工厂:
Geometry a = new GeometryFactory(new PrecisionModel(), 4326).createPoint(new Coordinate(0,0));
Geometry b = new GeometryFactory(new PrecisionModel(), 3857).createPoint(new Coordinate(1,1));
System.out.println(a.union(b).getSRID()); // 4326(a 的)
业务层必须自己保证 SRID 一致,JTS 不会帮你检查。这是第 19 章的核心话题之一。
4.11.2 setSRID 的正确用法
// 1) 给从 WKT 读入的几何补上 SRID
Geometry g = reader.read("POINT (116.4 39.9)");
g.setSRID(4326);
// 2) 批量设置
for (Geometry item : collection) {
item.setSRID(4326);
}
// 3) 从旧几何复制 SRID
Geometry copy = other.copy();
copy.setSRID(source.getSRID());
注意 setSRID 是原地修改,会改变对象状态。如果几何被多个线程共享,别在运行时改 SRID。
4.11.3 getUserData / setUserData
Geometry 自带一个“用户数据”槽位,可以挂任意对象:
Geometry g = reader.read("POINT (1 2)");
g.setUserData(Map.of("id", 1001, "name", "采样点"));
System.out.println(g.getUserData());
// 运算结果是否继承 userData?默认不继承(新对象),需要自己处理
这是承载业务属性的轻量方式(例如把数据库主键挂在几何上,空间查询命中后直接取)。但要注意:
getUserData()返回Object,需要类型转换;- 几何运算产生的新对象通常不保留
userData; - 持久化/序列化时不会自动带上,要自己维护映射。
生产项目更常见的做法是用“几何 + 属性对象”的组合(Feature),而不是塞 userData。
4.12 内存与创建性能
4.12.1 对象开销估算
一个 Point 包含:对象头 + 对 GeometryFactory 的引用 + CoordinateSequence 引用 + 包络缓存 + 序列对象本身。粗略估算(64 位 JVM,未压缩指针):
| 类型 | 大约字节数 | 备注 |
|---|---|---|
| Point(CoordinateArraySequence) | 150~250 | 含 Coordinate 对象 |
| LineString(1000 点,默认序列) | ~40 KB | 每点一个 Coordinate 对象 |
| LineString(1000 点,Packed double) | ~16 KB | 连续 double 数组 |
| Polygon(5 点外环) | 300~400 | 含 LinearRing |
这些数字随 JVM、是否压缩指针、JTS 版本变化,但量级关系是稳定的:Packed 序列能省 2~3 倍内存。
4.12.2 批量创建的性能建议
// 不推荐:每次都 new GeometryFactory
for (int i = 0; i < 100000; i++) {
GeometryFactory f = new GeometryFactory(); // 浪费
f.createPoint(new Coordinate(i, i)); // 每次 new Coordinate
}
// 推荐:工厂复用 + 坐标复用
GeometryFactory f = new GeometryFactory();
Coordinate c = new Coordinate();
for (int i = 0; i < 100000; i++) {
c.x = i; c.y = i;
Geometry p = f.createPoint(c); // 工厂内部复制,c 可复用
}
更进一步,如果用 PackedCoordinateSequenceFactory,可以直接从 double[] 构造序列,避免逐点 new Coordinate:
double[] xy = new double[2000];
for (int i = 0; i < 1000; i++) {
xy[2*i] = i;
xy[2*i+1] = i * 2;
}
CoordinateSequence seq = PackedCoordinateSequenceFactory.DOUBLE_FACTORY.create(xy, 2);
LineString line = packedGf.createLineString(seq);
4.12.3 何时该担心内存
| 顶点规模 | 建议 |
|---|---|
| < 1 万 | 默认实现即可,不值得优化 |
| 1 万 ~ 100 万 | 考虑 Packed 序列、分批处理 |
| > 100 万 | 必须分块、简化、或改用流式处理(第 20 章) |
4.12.4 几何复制:copy 与 clone
Geometry copy1 = g.copy(); // 推荐:返回独立副本
Geometry copy2 = g.clone(); // 旧 API,语义类似
// 要修改又不想影响原对象:
Geometry modified = g.copy();
modified.normalize(); // 原地修改副本
注意 copy() 是浅拷贝还是深拷贝?以源码为准:Geometry.copy() 会创建新的几何对象并复制坐标序列(深拷贝坐标数据),但 userData 的引用是共享的。业务代码把它当深拷贝用是安全的。
4.13 常见问题与避坑
Q1:createPolygon 报 “Points of LinearRing do not form a closed linestring”?
坐标数组首尾必须相同。检查最后一个坐标是否等于第一个(equals2D)。
Q2:多边形有洞时 getBoundary() 返回 MultiLineString?
是的,无洞时返回 LinearRing,有洞时返回 MultiLineString。类型判断要兼容两种。
Q3:getCoordinate() 返回的坐标能改吗?
不保证。要修改几何用 GeometryEditor 或重建(第 5 章)。
Q4:CoordinateXY 的 getZ() 返回什么?
返回 Double.NaN,不抛异常。判断是否有 Z 用 getCoordinateSequence().hasZ() 或检查 Double.isNaN。
Q5:equals 和 equalsTopo 的区别?
equals(Object 方法)通常是引用/结构比较;equalsTopo 是拓扑相等(顶点顺序不同也相等)。比较几何内容优先用 equalsTopo 或 equalsExact(带容差)。
Q6:为什么 GeometryCollection 参与 union 会抛异常?
JTS 的叠加运算不支持纯 GeometryCollection 输入,需要先展开成 Multi* 或分别运算。
Q7:空几何参与 distance 返回 0?
JTS 的实现细节,不符合数学直觉。业务代码先判 isEmpty()。
Q8:getEnvelope() 和 getEnvelopeInternal() 选哪个?
循环/索引里用 getEnvelopeInternal()(轻量),需要几何形式(如输出、空间查询)用 getEnvelope()。
Q9:SRID 不同能直接运算吗? 能,但不保证正确。JTS 不校验 SRID,结果取第一个输入的工厂。业务层应先转换到同一 CRS。
Q10:PackedCoordinateSequence 一定更快吗? 访问更快、内存更省,但写入与混合维度支持较弱。小几何用默认实现即可。
4.14 本章小结
- 坐标有
Coordinate/ XY / XYZ / XYM / XYZM 五个类,不是同一个类,比较要用equals2D/equals3D; - 坐标对象可变,但工厂创建时会复制,改原坐标不影响几何;
- 序列有
CoordinateArraySequence(默认)与PackedCoordinateSequence(省内存),默认工厂不是 Packed; Envelope是粗筛利器,getEnvelopeInternal()最轻量;空包络isNull()为 true;- 几何层次:Point / LineString(LinearRing) / Polygon / GeometryCollection(Multi*);
- 纯
GeometryCollection不能参与叠加运算,判断用isGeometryCollection(); GeometryFactory统一创建几何,创建时只做结构检查(不检查拓扑有效性);- 边界规则:点无边、闭合线无边、面边界是环(有洞时是 MultiLineString)、GeometryCollection 抛异常;
- 空几何是对象不是 null,运算行为有细节(distance 返回 0);
- 环方向影响序列化与部分软件兼容性,输出前 normalize;
- 几何运算默认返回新对象、不修改输入,可安全复用。
相关资源
- Geometry Javadoc:
https://locationtech.github.io/jts/javadoc/org/locationtech/jts/geom/Geometry.html - GeometryFactory Javadoc:
https://locationtech.github.io/jts/javadoc/org/locationtech/jts/geom/GeometryFactory.html - Coordinate Javadoc:
https://locationtech.github.io/jts/javadoc/org/locationtech/jts/geom/Coordinate.html - Envelope Javadoc:
https://locationtech.github.io/jts/javadoc/org/locationtech/jts/geom/Envelope.html - 上一章:第03章 - 核心架构与源码结构
- 下一章:第05章 - 几何创建、编辑与仿射变换