第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());  // 远大于实际面积

三个坑:

  1. 空几何的包络是“空包络”:isNull() 返回 true,getArea() 返回 0,不要以为是 (0,0)-(0,0);
  2. 包络相交不代表几何相交:包络只是粗筛,两个对角重叠的三角形包络可能相交但几何相离;
  3. 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)
        └── 计算包络、校验基本约束

两个重要行为:

  1. 不自动闭合:createPolygon(Coordinate[]) 要求传入的坐标已经闭合(首尾相同),否则抛 IllegalArgumentException;
  2. 不校验有效性:创建时只做基本结构检查(点数、闭合),不会检查自相交、孔在外部等拓扑问题。有效性要靠 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 章会系统讲):

  1. 外环与每个内环都闭合;
  2. 环之间不相交(孔不能跨过外环边界);
  3. 孔不能包含另一个孔(除非形成“岛”,那需要 MultiPolygon);
  4. 孔必须在多边形内部,而不是外部;
  5. 环自身不自相交。
// 无效:孔在外部
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章 - 几何创建、编辑与仿射变换