第05章 - 几何创建、编辑与仿射变换
前一章拆开了 JTS 的几何对象模型:Geometry 是抽象基类,Point、LineString、Polygon 各自持有 CoordinateSequence,而 GeometryFactory 负责把这些零件组装成合法的几何对象。但”知道类图”和”能造出一个几何”之间还隔着大量工程细节:坐标从哪来、环要不要闭合、空几何怎么表达、坐标序列用哪种实现、SRID 归谁管、精度模型什么时候生效。这些问题在写业务代码时几乎每天都会遇到,而 JTS 的答案往往是”工厂不管、你自己保证”,一旦理解错就会得到无效几何或静默的错误结果。
本章解决三类问题。第一类是”创建”:把 GeometryFactory 的全部 create* 方法、Coordinate[] 与 CoordinateList 两种输入方式、CoordinateSequence 的两种存储实现讲透,并说明 buildGeometry、toGeometry(Envelope)、createEmpty 这些容易被忽略的便捷入口各自的返回类型规则。第二类是”编辑”:JTS 的几何是不可变对象,任何修改都要生成新对象,官方给了三个层次的工具——GeometryEditor 保持结构不变只改坐标、GeometryTransformer 允许改变结构、GeometryMapper 面向集合做批量映射;再加上 GeometryExtracter 系列提取器与 GeometryCombiner,就构成了一套完整的”几何再加工”工具箱。第三类是”变换”:AffineTransformation 用 3×3 矩阵表达旋转、缩放、平移、错切、反射,理解矩阵组合顺序与逆变换是做好地图配准、坐标纠偏、图形摆正的前提。
阅读本章需要你已经了解第 04 章的几何类型体系与 Coordinate 家族;如果你还没搭好运行环境,请先看第 02 章。本章示例均基于 jts-core 1.20/1.21,可直接复制到 main 方法中运行,输出以注释形式标在关键行后。需要强调的是:JTS 不含坐标参考系定义,本章所有”坐标变换”都是平面笛卡尔意义下的仿射变换,与投影转换无关,投影问题见第 19 章。
5.1 GeometryFactory:一切几何的起点
5.1.1 构造工厂:三个参数决定行为
GeometryFactory 是 JTS 中唯一”官方”的几何构造入口。它的内部状态只有三个:PrecisionModel、SRID、CoordinateSequenceFactory,分别回答”坐标精度按什么规则理解”“几何属于哪个空间参考系”“坐标用什么结构存”。
// 1. 最常用:浮点精度、SRID=0、默认坐标序列工厂
GeometryFactory gf1 = new GeometryFactory();
// 2. 指定精度模型与 SRID
GeometryFactory gf2 = new GeometryFactory(new PrecisionModel(), 4326);
// 3. 指定固定精度(比例因子 1000,即小数点后 3 位)与 SRID
GeometryFactory gf3 = new GeometryFactory(new PrecisionModel(1000), 3857);
// 4. 指定坐标序列工厂(例如换成压缩存储)
GeometryFactory gf4 = new GeometryFactory(
new PrecisionModel(), 4326,
PackedCoordinateSequenceFactory.DOUBLE_FACTORY);
四个构造函数背后只有一个真正的构造器:new GeometryFactory(PrecisionModel, int SRID, CoordinateSequenceFactory)。另外两个单参数构造函数分别等价于”默认 SRID=0”和”默认精度模型”,不要被重载数量迷惑。
有一个事实和很多网上资料相反,必须在这里纠正:JTS 1.20/1.21 源码里 GeometryFactory 的默认坐标序列工厂是 CoordinateArraySequenceFactory.instance(),也就是”一个 Coordinate 对象数组”,而不是 PackedCoordinateSequenceFactory。想看源码可以直接打开 modules/core/src/main/java/org/locationtech/jts/geom/GeometryFactory.java,getDefaultCoordinateSequenceFactory() 一行写着答案。压缩序列在高性能场景更省内存,但需要显式传入,或者用 GeometryFactory 的子类覆盖。
工厂是线程安全的,这一点在官方 Javadoc 中明确写着,所以常见做法是全局持有一个 static final GeometryFactory 实例复用;相反,WKTReader、WKBReader 不是线程安全的(内部有可变缓冲),不能共享。
5.1.2 create* 家族总览
GeometryFactory 的方法看起来很多,但规律很清晰:每种几何类型都有”空构造”与”按坐标构造”两个版本,复合类型还多一个”按子几何数组构造”的版本。下表覆盖了全部 create* 方法(省略了返回数组的静态 toXxxArray 工具方法,它们只是把 Collection 转成具体数组)。
| 方法 | 输入 | 返回 | 备注 |
|---|---|---|---|
| createPoint() | 无 | Point | 空点,isEmpty() 为 true |
| createPoint(Coordinate) | Coordinate 或 null | Point | null 生成空点 |
| createPoint(CoordinateSequence) | 坐标序列或 null | Point | 序列长度应为 0 或 1 |
| createMultiPoint() | 无 | MultiPoint | 空集合 |
| createMultiPoint(Point[]) | Point 数组 | MultiPoint | 元素不可为 null |
| createMultiPointFromCoords(Coordinate[]) | 坐标数组 | MultiPoint | 推荐替代已过时的重载 |
| createMultiPoint(CoordinateSequence) | 坐标序列 | MultiPoint | 每个坐标拆成一个 Point |
| createLineString() | 无 | LineString | 空线 |
| createLineString(Coordinate[]) | 坐标数组 | LineString | 长度 0 或 ≥2 |
| createLineString(CoordinateSequence) | 坐标序列 | LineString | 不做闭合检查 |
| createLinearRing() | 无 | LinearRing | 空环 |
| createLinearRing(Coordinate[]) | 坐标数组 | LinearRing | 必须闭合,长度 0 或 ≥3 |
| createLinearRing(CoordinateSequence) | 坐标序列 | LinearRing | 同上 |
| createPolygon() | 无 | Polygon | 空面 |
| createPolygon(LinearRing) | 外环 | Polygon | 等价于无洞 |
| createPolygon(LinearRing, LinearRing[]) | 外环 + 洞 | Polygon | 洞可传 null |
| createPolygon(Coordinate[]) | 外环坐标 | Polygon | 内部先建 LinearRing |
| createPolygon(CoordinateSequence) | 外环序列 | Polygon | 内部先建 LinearRing |
| createMultiLineString() | 无 | MultiLineString | 空集合 |
| createMultiLineString(LineString[]) | 线数组 | MultiLineString | 元素不可为 null |
| createMultiPolygon() | 无 | MultiPolygon | 空集合 |
| createMultiPolygon(Polygon[]) | 面数组 | MultiPolygon | 元素不可为 null |
| createGeometryCollection() | 无 | GeometryCollection | 空集合 |
| createGeometryCollection(Geometry[]) | 几何数组 | GeometryCollection | 可混合类型 |
| createEmpty(int) | 维度 -1/0/1/2 | Geometry | -1 对应集合 |
| createGeometry(Geometry) | 任意几何 | Geometry | 深拷贝并换用本工厂 |
| buildGeometry(Collection) | 几何集合 | Geometry | 自动选择最具体类型 |
| toGeometry(Envelope) | 包络 | Geometry | 按包络形状生成点/线/面 |
这张表有两个易错点值得单独说明。
第一,createMultiPoint(Coordinate[]) 在源码里被标注为 @deprecated,Javadoc 明确要求改用 createMultiPointFromCoords(Coordinate[])。两者当前实现完全一样,但保留旧名只是为了二进制兼容,新代码不应再调用;很多旧博客仍在传播这个过时写法。
第二,createLineString(Coordinate[]) 不会替你补充点数,也不检查”至少两个点”;LineString 的构造函数只在序列非空且点数不足 2 时抛异常。也就是说 new Coordinate[]{p} 会抛 IllegalArgumentException,而 null 与空数组都会得到一条空线。真正做数据清洗时要自己先用 CoordinateArrays.atLeastNCoordinatesOrNothing(2, coords) 之类的工具裁剪。
5.1.3 空几何、null 与异常边界
JTS 的一致性设计是:”空”用类型化的空几何表达,而不是 null。工厂的每个无参 createXxx() 都返回对应类型的空几何,例如 createPolygon() 得到 POLYGON EMPTY,createGeometryCollection() 得到 GEOMETRYCOLLECTION EMPTY。空几何仍然带类型、带 SRID、带工厂,因此可以安全参与后续运算,这一点比返回 null 对业务代码友好得多。
但 null 并没有被完全禁止。createPoint((Coordinate) null) 返回空点,createPolygon((LinearRing) null) 返回空面,createLineString((Coordinate[]) null) 返回空线——这些都是源码里显式处理的。反过来,把 null 塞进数组(例如 new Coordinate[]{p, null})就会在构造函数深处触发 NullPointerException,异常信息通常看不出是哪个元素为空。
真正会抛异常的是结构违规:环不闭合、环点数不足。LinearRing 的校验逻辑写在 validateConstruction() 里,两条错误信息分别是 “Points of LinearRing do not form a closed linestring” 和 “Invalid number of points in LinearRing (found X - must be 0 or >= 3)”。注意 MINIMUM_VALID_SIZE = 3,所以三个点(首尾相同,实际只有两个不同点)在构造期是允许的,只是它拓扑上退化了,后续 isSimple()、isValid() 会报问题,必要时用第 17 章的 GeometryFixer 修复。
5.1.4 工厂不做精度舍入
这是 GeometryFactory 最容易误解的一条语义,官方 Javadoc 用加粗写着:工厂的构造函数方法不会以任何方式修改输入坐标,尤其不会按 PrecisionModel 做舍入,它假定输入已经满足精度要求。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.PrecisionModel;
public class PrecisionTrap {
public static void main(String[] args) {
// 固定精度:比例因子 1000,即保留 3 位小数
PrecisionModel pm = new PrecisionModel(1000);
GeometryFactory gf = new GeometryFactory(pm, 0);
Point p = gf.createPoint(new Coordinate(1.23456, 2.34567));
System.out.println(p); // POINT (1.23456 2.34567),并没有被截断
// 想真正落到精度网格上,必须显式调用 makePrecise
Coordinate c = new Coordinate(1.23456, 2.34567);
pm.makePrecise(c);
System.out.println(c.x + ", " + c.y); // 1.235, 2.346
}
}
这个设计是刻意为之:工厂是”装配工”而不是”质检员”。如果你需要按网格取整,可以用 PrecisionModel.makePrecise,或者用第 18 章的 GeometryPrecisionReducer 做带拓扑修复的整体降精度。理解这条语义能避免一类经典 bug:以为设了 PrecisionModel(1000) 就自动得到 3 位小数,结果发现坐标原样保留,空间索引与叠加分析的行为和预期不一致。
5.2 用坐标数组与 CoordinateList 组装几何
5.2.1 Coordinate[] 直建与环的闭合约束
Coordinate[] 是最直接的输入形式:自己保证顺序、自己保证闭合。对线来说只要不少于 2 个点;对面来说,外环必须”首尾相等”,否则 createPolygon(Coordinate[]) 会在内部创建 LinearRing 时抛异常。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LinearRing;
import org.locationtech.jts.geom.Polygon;
public class RingClosure {
public static void main(String[] args) {
GeometryFactory gf = new GeometryFactory();
// 正确:首尾坐标相同
Coordinate[] shellPts = new Coordinate[] {
new Coordinate(0, 0), new Coordinate(0, 4),
new Coordinate(4, 4), new Coordinate(4, 0),
new Coordinate(0, 0)
};
LinearRing shell = gf.createLinearRing(shellPts);
Polygon poly = gf.createPolygon(shell, null);
System.out.println(poly.getArea()); // 16.0
System.out.println(poly.getNumPoints()); // 5
// 错误示范:忘记闭合会抛 IllegalArgumentException
try {
gf.createLinearRing(new Coordinate[] {
new Coordinate(0, 0), new Coordinate(0, 4), new Coordinate(4, 4)
});
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
// Points of LinearRing do not form a closed linestring
}
}
}
注意上面”错误示范”里的三个点同时也违反了闭合要求,所以先触发闭合检查。环方向在构造期不做限制:顺时针、逆时针都合法,JTS 只在需要时(例如 GeoJSON 输出、Buffer 生成)才关心方向,这一点在第 06、07 章会反复出现。
5.2.2 CoordinateList:可增长的坐标缓冲
手工拼数组很痛苦,尤其坐标来自流式解析或过滤时。CoordinateList 是 JTS 提供的轻量坐标容器:底层是数组,自动扩容,可以控制是否允许重复点,还能一键闭合。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateList;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Polygon;
public class CoordinateListDemo {
public static void main(String[] args) {
CoordinateList list = new CoordinateList();
// 第二个参数 allowRepeated=false 表示跳过与上一个点重合的点
list.add(new Coordinate(0, 0), false);
list.add(new Coordinate(0, 0), false); // 被忽略
list.add(new Coordinate(0, 5), false);
list.add(new Coordinate(5, 5), false);
list.add(new Coordinate(5, 0), false);
System.out.println(list.size()); // 4
list.closeRing(); // 末尾补上首点
System.out.println(list.size()); // 5
Coordinate[] pts = list.toCoordinateArray();
GeometryFactory gf = new GeometryFactory();
Polygon poly = gf.createPolygon(pts);
System.out.println(poly.getArea()); // 25.0
}
}
closeRing() 的实现很朴素:取第 0 个坐标的副本,以 allowRepeated=false 追加;如果列表末尾已经等于首点,add 会因重复检测直接忽略,所以重复调用是幂等的。这个”幂等”特性让它在流式解析里可以放心多次调用。
CoordinateList 还有两个实用方法:add(Coordinate[] coords, boolean allowRepeated) 批量追加,addAll(Collection, boolean) 追加任意集合。如果需要按方向读取,toCoordinateArray(boolean isForward) 可以一次拿到正序或逆序数组,省去自己写循环。
5.2.3 buildGeometry:把零件装成”最具体”的整体
当手上是一堆几何、数量不定、类型也不定时,buildGeometry(Collection) 会自动选择”能装下所有这些零件的最具体类型”:
- 空集合 → 空 GeometryCollection;
- 只有一个元素 → 直接返回该元素本身(不复制、不包装);
- 全部同类型且多于一个 → 对应的 Multi 类型,例如多个 Polygon 得到 MultiPolygon;
- 类型不一致,或含有 GeometryCollection → GeometryCollection。
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
public class BuildGeometryDemo {
public static void main(String[] args) {
GeometryFactory gf = new GeometryFactory();
List<Geometry> parts = new ArrayList<Geometry>();
parts.add(gf.createPolygon(new Coordinate[] {
new Coordinate(0, 0), new Coordinate(0, 1),
new Coordinate(1, 1), new Coordinate(1, 0), new Coordinate(0, 0)
}));
parts.add(gf.createPolygon(new Coordinate[] {
new Coordinate(2, 2), new Coordinate(2, 3),
new Coordinate(3, 3), new Coordinate(3, 2), new Coordinate(2, 2)
}));
Geometry built = gf.buildGeometry(parts);
System.out.println(built.getGeometryType()); // MultiPolygon
System.out.println(built.getNumGeometries()); // 2
// 单个元素时不会被包装
List<Geometry> one = new ArrayList<Geometry>();
one.add(parts.get(0));
System.out.println(gf.buildGeometry(one).getGeometryType()); // Polygon
}
}
这里有一个隐蔽的坑:buildGeometry 不会”拍平”输入中的 Multi 几何。如果 parts 里放的是一个 MultiPolygon,返回值会是包含该 MultiPolygon 的 GeometryCollection,而不是 MultiPolygon。需要拍平时应当自己先遍历拆开,或者用 5.7 节的 GeometryMapper.flatMap。
5.2.4 toGeometry(Envelope) 与 createEmpty(dimension)
两个不起眼但很好用的入口。toGeometry(Envelope) 把包络转成几何,规则是:null 包络 → 空点;退化为一个点 → Point;只在 X 或 Y 方向退化 → 水平/垂直线;否则 → 矩形 Polygon。源码注释里写明生成的是 CW 方向(顺时针)的环,坐标顺序为 (minX,minY) → (minX,maxY) → (maxX,maxY) → (maxX,minY) → 闭合。
createEmpty(int dimension) 按维度造空几何:-1 → GeometryCollection,0 → Point,1 → LineString,2 → Polygon,其他值抛 IllegalArgumentException。写通用算法时这个方法是”类型与维度对齐”的快捷方式,例如叠加分析的结果为空时需要一个和输入维度一致的空几何。
下图把本章前半部分的构造路径串起来,方便建立整体印象。
输入数据
│
┌────────────────────┼─────────────────────┐
│ │ │
Coordinate[] CoordinateList WKT / WKB / GeoJSON
│ │ │
│ toCoordinateArray() │
│ │ │
└──────────┬─────────┘ │
│ │
▼ ▼
GeometryFactory.createXxx() WKTReader / WKBReader
│ │
└───────────────┬───────────────┘
▼
Geometry(带工厂、SRID、CS)
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
GeometryEditor GeometryTransformer AffineTransformation
(结构不变改坐标) (结构可变重建) (只改 X/Y 坐标)
5.3 CoordinateSequence:坐标真正存放的地方
5.3.1 两种实现:数组序列与压缩序列
Geometry 本身不直接持有 Coordinate[],而是持有 CoordinateSequence。这个间接层带来两个好处:一是不同存储实现可以互换(对象数组 vs 基本类型数组),二是 X/Y/Z/M 的维度可以在序列级别描述,而不是靠 Coordinate 子类硬编码。
JTS 提供两个开箱即用的实现:
- CoordinateArraySequence:内部是 Coordinate[],每个点一个对象。默认工厂 CoordinateArraySequenceFactory.instance() 产生它。优点是实现直观、与 Coordinate API 无缝衔接;缺点是每个点一个对象头,百万级坐标时内存开销明显。
- PackedCoordinateSequence:内部是 double[](或 float[]),按 dimension 连续平铺。工厂是 PackedCoordinateSequenceFactory.DOUBLE_FACTORY / FLOAT_FACTORY。优点是内存紧凑、缓存友好、遍历快;缺点是随机访问要算下标,而且不能直接拿到 Coordinate 对象。
5.3.2 维度与 measures
维度在这里有两个相关但不同的概念:dimension 是序列里每个点占的 ordinate 个数(2、3、4),measures 表示其中有多少个是”测量值 M”而不是高程 Z。JTS 的约定是:
- dimension = 2:只有 X、Y;
- dimension = 3 且 measures = 0:X、Y、Z;
- dimension = 3 且 measures = 1:X、Y、M;
- dimension = 4 且 measures = 1:X、Y、Z、M。
CoordinateSequenceFactory 的 create(int size, int dimension) 与 create(int size, int dimension, int measures) 就是给这两个参数用的。Coordinate 家族与之一一对应:CoordinateXY、Coordinate、CoordinateXYM、CoordinateXYZM,创建时可以用 Coordinates.create(dimension) 或 Coordinates.create(dimension, measures) 让 JTS 帮你选类型。CoordinateSequence 接口本身提供 getDimension()、getMeasures()、getOrdinate(i, ordinateIndex)、setOrdinate(i, ordinateIndex, value)、size()、copy()、toCoordinateArray() 等方法,其中 ordinateIndex 的取值请用 CoordinateSequence.X / Y / Z / M 常量,不要写死 0/1/2/3。
| 序列实现 | 底层存储 | 默认工厂 | 内存占用 | 适用场景 |
|---|---|---|---|---|
| CoordinateArraySequence | Coordinate[] | CoordinateArraySequenceFactory | 高(每点一个对象) | 通用场景、频繁取 Coordinate |
| PackedCoordinateSequence(double) | double[] | PackedCoordinateSequenceFactory.DOUBLE_FACTORY | 低,约为前者的三分之一 | 大数据量、只读分析、索引构建 |
| PackedCoordinateSequence(float) | float[] | PackedCoordinateSequenceFactory.FLOAT_FACTORY | 最低 | 精度要求不高的可视化/瓦片数据 |
需要提醒的是:更换序列实现并不是免费的午餐。CoordinateArraySequenceFactory.create(CoordinateSequence) 会逐个复制坐标,而 PackedCoordinateSequenceFactory.create(CoordinateSequence) 也只支持部分维度组合;跨实现复制时如果 dimension 与 measures 不匹配,可能出现 Z 值丢失或 M 被当成 Z。切换前先确认源序列的 getDimension() 与 getMeasures()。
5.3.3 Packed 序列的构造与读写
直接构造 PackedCoordinateSequence 是最容易踩坑的地方,因为它的构造器接受的是”平铺后的原始数组 + 维度”,顺序必须自己保证。
import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.geom.impl.PackedCoordinateSequenceFactory;
public class PackedDemo {
public static void main(String[] args) {
// 三个点、二维:(x0,y0, x1,y1, x2,y2)
double[] flat = new double[] { 0, 0, 1, 2, 3, 3 };
PackedCoordinateSequenceFactory csf = PackedCoordinateSequenceFactory.DOUBLE_FACTORY;
CoordinateSequence seq = csf.create(flat, 2);
GeometryFactory gf = new GeometryFactory(new PrecisionModel(), 0, csf);
LineString line = gf.createLineString(seq);
System.out.println(line); // LINESTRING (0 0, 1 2, 3 3)
System.out.println(seq.getDimension()); // 2
System.out.println(seq.getX(1) + " " + seq.getY(1)); // 1.0 2.0
// 按下标读写,0=X 1=Y 2=Z 3=M
seq.setOrdinate(0, 0, 10);
System.out.println(line.getCoordinateN(0).x); // 10.0,序列是共享的
}
}
最后一行值得注意:PackedCoordinateSequence 是可变对象,geometry 只是持有引用,直接改序列会”改到”几何。JTS 的不可变性约定是约定而非强制,共享序列时要格外小心,必要时用 seq.copy() 或 geometry.copy() 断开关联。
至于什么时候该把默认序列换成 Packed,经验值是这样:坐标量在十万以内,两者差异可以忽略;百万级以上并且主要做只读分析(距离、叠加、索引)时,Packed 的收益明显;如果算法里频繁调用 getCoordinate() 生成 Coordinate 对象,Packed 反而因为每次都要 new Coordinate 而变慢。要换的话,最省事的做法是构造 GeometryFactory 时传入 PackedCoordinateSequenceFactory,然后用 createGeometry(Geometry) 把旧几何整体复制到新工厂,下一节马上讲这个用法。
5.4 工厂切换、SRID 与 PrecisionModel
5.4.1 createGeometry:深拷贝并换工厂
createGeometry(Geometry g) 是源码里少数几个直接体现”工厂职责”的方法:它内部创建一个 GeometryEditor,并传入一个 CoordSeqCloneOp,把输入几何的每条 CoordinateSequence 用本工厂的序列工厂重新创建一遍,从而实现深拷贝 + 换实现 + 换 SRID/精度模型。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.geom.impl.PackedCoordinateSequenceFactory;
import org.locationtech.jts.io.WKTReader;
public class FactorySwitch {
public static void main(String[] args) throws Exception {
Geometry g = new WKTReader().read("LINESTRING (0 0, 10 0)");
System.out.println(g.getSRID() + " / " + g.getFactory().getCoordinateSequenceFactory().getClass().getSimpleName());
// 0 / CoordinateArraySequenceFactory
GeometryFactory target = new GeometryFactory(
new PrecisionModel(), 4326, PackedCoordinateSequenceFactory.DOUBLE_FACTORY);
Geometry moved = target.createGeometry(g);
System.out.println(moved.getSRID()); // 4326
System.out.println(moved == g); // false,深拷贝
System.out.println(moved.getFactory().getCoordinateSequenceFactory().getClass().getSimpleName());
// PackedCoordinateSequenceFactory
System.out.println(moved.equalsExact(g)); // true,坐标一致
}
}
这也是给几何”改 SRID”的标准手段之一:JTS 的 Geometry.setSRID(int) 虽然存在,但它只改当前对象(而且子几何的 SRID 不会跟着变),在需要整棵树统一 SRID 时,用新工厂 createGeometry 更稳妥。
5.4.2 SRID 的传播规则
SRID 存在 Geometry 上,由 GeometryFactory 的构造参数决定初始值。几个必须记住的规则:
- 工厂创建的所有几何都会带上工厂的 SRID,包括空几何。
- Geometry 组合(MultiPolygon 等)不检查子几何 SRID 是否一致,也不复制工厂——子几何保留自己的工厂与 SRID。也就是说,用 SRID=4326 的工厂去包装一个 SRID=3857 的 Polygon,结果 MultiPolygon 的 SRID 是 4326,但内部 Polygon 仍是 3857,这种不一致不会被发现。
- setSRID(int) 只设置当前对象,不会递归。要让整棵树统一,只能重建几何(createGeometry + 目标工厂)或自己递归设置。
- WKT 不携带 SRID(除非用非标准扩展),WKB 的 EWKB 扩展和 GeoJSON 的 crs 字段才带 SRID,详见第 06、07 章。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Polygon;
public class SridTrap {
public static void main(String[] args) {
GeometryFactory gf4326 = new GeometryFactory(new PrecisionModel(), 4326);
GeometryFactory gf3857 = new GeometryFactory(new PrecisionModel(), 3857);
Polygon a = gf4326.createPolygon(new Coordinate[] {
new Coordinate(0, 0), new Coordinate(0, 1),
new Coordinate(1, 1), new Coordinate(1, 0), new Coordinate(0, 0)
});
Polygon b = gf3857.createPolygon(new Coordinate[] {
new Coordinate(2, 2), new Coordinate(2, 3),
new Coordinate(3, 3), new Coordinate(3, 2), new Coordinate(2, 2)
});
org.locationtech.jts.geom.MultiPolygon mp = gf4326.createMultiPolygon(new Polygon[] { a, b });
System.out.println(mp.getSRID()); // 4326
System.out.println(mp.getGeometryN(1).getSRID()); // 3857,没有被统一
}
}
5.4.3 PrecisionModel 在什么时候起作用
PrecisionModel 是”元数据 + 工具”的组合:它是工厂的元数据,可以被查询(getPrecisionModel()),也可以被显式调用(makePrecise);但它不会自动作用于任何几何运算。真正使用它的地方包括:
- WKTWriter:输出时按 PrecisionModel 决定小数位数(第 06 章);
- GeometryPrecisionReducer:按模型把坐标舍入并修复拓扑(第 17、18 章);
- 部分叠加算法内部会读取精度模型来构建网格(OverlayNG 的 precisionModel 参数);
- Buffer 的某些实现会参考它。
因此”给工厂设了 Fixed 精度”并不等于”之后所有运算都在网格上”。工程上更常见的做法是:数据入库前用 GeometryPrecisionReducer 统一降到目标精度,然后用浮点工厂承载,避免精度模型和实际坐标不一致造成的困惑。
5.5 GeometryEditor:结构不变前提下的”编辑”
5.5.1 三个层次的编辑操作
GeometryEditor 解决的问题是:手上有几何,想改坐标、删洞、删子几何,但不允许改变几何类型。它的工作方式是遍历几何树,对每个组件调用用户提供的”编辑操作”,再按原结构重新组装。官方把操作分成三层,选择哪一层决定了你能改什么:
| 操作接口 | 抽象方法 | 能改什么 | 典型用途 |
|---|---|---|---|
| GeometryEditorOperation | edit(Geometry, GeometryFactory) | 整个几何(返回 null 表示删除) | 按类型过滤、整体替换 |
| CoordinateOperation | edit(Coordinate[], Geometry) | 坐标数组 | 简单坐标变换、按条件删点 |
| CoordinateSequenceOperation | edit(CoordinateSequence, Geometry) | 坐标序列 | 保维度/保 measures 的精细修改 |
另外 JTS 内置了 NoOpGeometryOperation,它什么都不改,只用于”换工厂”:new GeometryEditor(targetFactory).edit(g, new GeometryEditor.NoOpGeometryOperation()),效果与 createGeometry 接近,但不重建坐标序列。
5.5.2 用 CoordinateOperation 做”加偏”与”删点”
下面这个例子把几何整体向东北平移 100 米(纯坐标层面),并在编辑过程中顺手删掉与上一个点重复的冗余点。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateList;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryEditor;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;
public class EditorDemo {
public static void main(String[] args) throws Exception {
GeometryFactory gf = new GeometryFactory();
Geometry g = new WKTReader(gf).read(
"POLYGON ((0 0, 0 10, 10 10, 10 10, 10 0, 0 0))"); // 故意带一个重复点
GeometryEditor editor = new GeometryEditor(gf);
Geometry shifted = editor.edit(g, new GeometryEditor.CoordinateOperation() {
public Coordinate[] edit(Coordinate[] coordinates, Geometry geometry) {
CoordinateList list = new CoordinateList();
for (Coordinate c : coordinates) {
Coordinate moved = new Coordinate(c.x + 100, c.y + 100, c.getZ());
list.add(moved, false); // false:去掉重复点
}
return list.toCoordinateArray();
}
});
System.out.println(shifted);
// POLYGON ((100 100, 100 110, 110 110, 110 100, 100 100))
System.out.println(shifted.getNumPoints()); // 5,重复点已被移除
}
}
注意这里删掉了一个点,但几何类型仍是 Polygon,结构合法性由你负责——GeometryEditor 的 Javadoc 特意声明”You are responsible for ensuring that the modified geometry is valid”。如果你删点删到环只剩 2 个点,createLinearRing 会直接抛异常,这是 JTS 少数会立刻”翻脸”的地方。
5.5.3 删除洞与 setCopyUserData
GeometryEditorOperation 返回 null 表示删除该组件。利用这一点可以做一个”去掉所有洞”的工具:
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryEditor;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.io.WKTReader;
public class DropHoles {
static class DropHolesOp extends GeometryEditor.CoordinateSequenceOperation {
public org.locationtech.jts.geom.CoordinateSequence edit(
org.locationtech.jts.geom.CoordinateSequence seq, Geometry geometry) {
return seq; // 坐标序列原样返回
}
}
public static void main(String[] args) throws Exception {
GeometryFactory gf = new GeometryFactory();
Geometry poly = new WKTReader(gf).read(
"POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0), (2 2, 2 3, 3 3, 3 2, 2 2))");
GeometryEditor editor = new GeometryEditor(gf);
editor.setCopyUserData(true); // 默认不复制 UserData,需要时显式打开
poly.setUserData("parcel-42");
Geometry noHoles = editor.edit(poly, new GeometryEditor.GeometryEditorOperation() {
public Geometry edit(Geometry g, GeometryFactory factory) {
if (g instanceof Polygon && ((Polygon) g).getNumInteriorRing() > 0) {
Polygon p = (Polygon) g;
return factory.createPolygon(p.getExteriorRing().getCoordinateSequence());
}
return g; // 其余组件原样保留
}
});
System.out.println(noHoles); // POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0))
System.out.println(noHoles.getUserData()); // parcel-42
}
}
这个例子同时说明了两点:一是 GeometryEditorOperation 作用在”整个几何”层面,可以整体替换;二是 UserData 默认不复制,因为它是任意 Object 引用,深拷贝语义不明确,JTS 选择保守行为。同理,删除 MultiPolygon 中的某个面时返回 null 即可,父级会自动跳过该元素,这是 GeometryEditor 处理”删除向上传播”的方式。
5.6 GeometryTransformer:允许改变结构的转换
5.6.1 模板方法调用链
当修改会改变几何结构(例如把 Polygon 降成 LineString、把环转成线、去掉空组件)时,GeometryEditor 就无能为力了,此时应该用 GeometryTransformer。它是一个典型的模板方法模式:子类只覆盖自己关心的几个钩子,transform(Geometry) 负责递归遍历并重建整棵树。
GeometryTransformer.transform(input)
│
├── instanceof Point ──────────► transformPoint()
├── instanceof LinearRing ─────► transformLinearRing()
├── instanceof LineString ─────► transformLineString()
├── instanceof Polygon ────────► transformPolygon()
├── instanceof MultiPoint ─────► transformMultiPoint()
├── instanceof MultiLineString ► transformMultiLineString()
├── instanceof MultiPolygon ───► transformMultiPolygon()
└── instanceof GeometryCollection ► transformGeometryCollection()
│
▼
每个原子几何最终调用 transformCoordinates(seq, parent)
│
▼
默认实现:copy(seq) → 原样复制,不修改坐标
可覆盖的钩子包括 transformCoordinates(CoordinateSequence, Geometry)、transformPoint、transformLineString、transformLinearRing、transformPolygon、transformMultiPoint、transformMultiLineString、transformMultiPolygon、transformGeometryCollection,以及两个工厂方法 createCoordinateSequence(Coordinate[]) 与 copy(CoordinateSequence)。默认实现都是”原样复制”,所以一个什么都不覆盖的子类等价于深拷贝。
5.6.2 典型子类:去掉 Z 值并丢弃空几何
下面实现一个降维转换器:把所有坐标压成二维,并且在转换过程中丢弃空组件。注意覆盖 transformCoordinates 后,Polygon 的构造仍然由默认的 transformPolygon 负责,它会用新的序列重建环。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.util.GeometryTransformer;
import org.locationtech.jts.geom.impl.CoordinateArraySequenceFactory;
import org.locationtech.jts.io.WKTReader;
public class DropZTransformer extends GeometryTransformer {
private final GeometryFactory factory = new GeometryFactory();
@Override
protected CoordinateSequence transformCoordinates(CoordinateSequence coords, Geometry parent) {
Coordinate[] pts = new Coordinate[coords.size()];
for (int i = 0; i < coords.size(); i++) {
pts[i] = new Coordinate(coords.getX(i), coords.getY(i)); // 只保留 XY
}
return CoordinateArraySequenceFactory.instance().create(pts);
}
@Override
protected Geometry transformPolygon(org.locationtech.jts.geom.Polygon geom, Geometry parent) {
Geometry result = super.transformPolygon(geom, parent);
return result; // 空面在父级组装时会被跳过
}
public static void main(String[] args) throws Exception {
Geometry g = new WKTReader().read(
"GEOMETRYCOLLECTION (POINT Z (1 2 3), LINESTRING Z (0 0 5, 1 1 6))");
Geometry out = new DropZTransformer().transform(g);
System.out.println(out);
// GEOMETRYCOLLECTION (POINT (1 2), LINESTRING (0 0, 1 1))
System.out.println(out.getCoordinate().getZ()); // NaN
}
}
5.6.3 GeometryEditor 与 GeometryTransformer 的取舍
| 维度 | GeometryEditor | GeometryTransformer |
|---|---|---|
| 结构性 | 类型结构保持不变 | 可改变类型与组件数量 |
| 入口 | edit(Geometry, Operation) | transform(Geometry)(final) |
| 扩展点 | Operation 接口(3 种粒度) | 覆盖 protected 钩子方法 |
| 删除组件 | 返回 null | 返回 null 或不合法的类型 |
| 典型使用者 | GeometryPrecisionReducer(精度降维时用子类) | PrecisionReducerTransformer、Densifier、GeometrySnapper |
| 适用场景 | 改坐标、删洞、换工厂 | 降维、类型归并、过滤空组件 |
选择原则很直接:如果”输入 Polygon 输出必须还是 Polygon”,用 GeometryEditor;如果输出类型可能变(Polygon 变 LineString、Collection 变 Multi),用 GeometryTransformer。
需要补充的是,JTS 自己在 org.locationtech.jts.precision 包里就提供了两个现成的 Transformer 实现:PrecisionReducerTransformer 与 PointwisePrecisionReducerTransformer,前者在降精度的同时保证拓扑一致(必要时用 GeometryFixer 思路修复),后者只做逐点舍入、不保证拓扑。需要按网格降精度时,直接用它们比自己写更安全。
5.7 GeometryMapper 与 GeometryCombiner
5.7.1 map 与 flatMap 的语义差别
GeometryMapper 是面向”集合式几何”的批量映射工具,只有两个静态方法和一个 MapOp 接口,但语义差别很关键:
- map(Geometry geom, MapOp op):只映射第一层成员,保留原有的层次结构;null 结果被跳过;返回类型由 buildGeometry 决定。
- flatMap(Geometry geom, int emptyDim, MapOp op):递归展开所有层级的集合,对每个原子几何应用 op,再把非空结果拍平成一个”最具体类型”的几何;如果全部为空,返回 createEmpty(emptyDim)。
- map(Collection geoms, MapOp op):批量版本,返回 Collection,不做装配。
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.util.GeometryMapper;
import org.locationtech.jts.io.WKTReader;
public class MapperDemo {
public static void main(String[] args) throws Exception {
GeometryFactory gf = new GeometryFactory();
Geometry gc = new WKTReader(gf).read(
"GEOMETRYCOLLECTION (POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0)), POINT (5 5))");
// 对每个成员做缓冲
Geometry buffered = GeometryMapper.map(gc, new GeometryMapper.MapOp() {
public Geometry map(Geometry g) {
return g.buffer(1);
}
});
System.out.println(buffered.getGeometryType()); // GeometryCollection
System.out.println(buffered.getNumGeometries()); // 2
// 逐点平移并拍平
Geometry moved = GeometryMapper.flatMap(gc, 0, new GeometryMapper.MapOp() {
public Geometry map(Geometry g) {
return g.copy(); // 占位:真实场景在这里做变换
}
});
System.out.println(moved.getGeometryType()); // GeometryCollection
}
}
flatMap 的 emptyDim 参数决定”什么都没映射出来”时的兜底维度:传 0 得到 POINT EMPTY,传 2 得到 POLYGON EMPTY,传 -1 得到 GEOMETRYCOLLECTION EMPTY。这个设计对写通用 ET L 管道很有用,因为下游往往要求”永远返回一个几何,不允许 null”。
5.7.2 GeometryCombiner:合并同类几何
GeometryCombiner 专门解决”多个几何合成一个”的问题,它与 buildGeometry 的区别在于:combiner 有明确的合并规则,并且支持传入提取器提前把几何拆成组件。
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.util.GeometryCombiner;
public class CombinerDemo {
public static void main(String[] args) {
GeometryFactory gf = new GeometryFactory();
Geometry line = gf.createLineString(new Coordinate[] {
new Coordinate(0, 0), new Coordinate(1, 1)
});
Geometry point = gf.createPoint(new Coordinate(2, 2));
// 直接合并两个同维度/不同维度几何
Geometry combined = GeometryCombiner.combine(line, point);
System.out.println(combined.getGeometryType()); // GeometryCollection
// 合并两个 MultiLineString,结果会被拍平成 MultiLineString
List<Geometry> list = new ArrayList<Geometry>();
list.add(line);
list.add(line);
System.out.println(GeometryCombiner.combine(list).getGeometryType()); // MultiLineString
}
}
实现上,GeometryCombiner 会先把输入里的 GeometryCollection 展开成原子几何,再按类型归类;全部同类型时输出对应 Multi 类型,混合类型时输出 GeometryCollection。这一点比 buildGeometry 更”智能”,也是很多空间 ETL 代码里合并分片结果的首选。
5.8 提取器家族:从混合集合中捞出想要的类型
5.8.1 五个提取器的分工
现实数据经常是”一个 GEOMETRYCOLLECTION 里什么都有”,做分析前往往需要按类型拆开。JTS 在 org.locationtech.jts.geom.util 包中提供了一组提取器,它们的共同套路是:静态方法 + 内部 GeometryFilter,遍历时把匹配的组件加入 List。
| 类 | 静态方法 | 提取内容 | 说明 |
|---|---|---|---|
| GeometryExtracter | extract(Geometry, Class, List) 或 extract(Geometry, String, List) | 指定类型的所有几何 | Class 用 Polygon.class 等;String 用 “Polygon” 等 |
| LineStringExtracter | getLines(Geometry[, List]) / getGeometry(Geometry) | 所有 LineString 组件 | LinearRing 也算 LineString |
| PolygonExtracter | getPolygons(Geometry[, List]) | 所有 Polygon 组件 | 不含 MultiPolygon 包装 |
| PointExtracter | getPoints(Geometry[, List]) | 所有 Point 组件 | 常用于统计点数 |
| LinearComponentExtracter | getLines(Geometry[, …]) / getGeometry(…) | 所有线性组件 | 可选择把环强制转成 LineString |
注意 GeometryExtracter 的 Class 参数匹配的是”实例类型”,所以传 LineString.class 会把 LinearRing 一起捞出来;传 Polygon.class 只捞面。String 版本按 getGeometryType() 匹配,返回的是标准类型名。
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.geom.util.GeometryExtracter;
import org.locationtech.jts.geom.util.LineStringExtracter;
import org.locationtech.jts.geom.util.PointExtracter;
import org.locationtech.jts.geom.util.PolygonExtracter;
import org.locationtech.jts.io.WKTReader;
public class ExtractDemo {
public static void main(String[] args) throws Exception {
GeometryFactory gf = new GeometryFactory();
Geometry gc = new WKTReader(gf).read(
"GEOMETRYCOLLECTION (POINT (1 1), LINESTRING (0 0, 2 2),"
+ " POLYGON ((0 0, 0 3, 3 3, 3 0, 0 0)))");
List<Point> pts = PointExtracter.getPoints(gc);
List<LineString> lines = LineStringExtracter.getLines(gc);
List<Polygon> polys = PolygonExtracter.getPolygons(gc);
System.out.println(pts.size() + " / " + lines.size() + " / " + polys.size()); // 1 / 1 / 1
// 把线合并成一条 MultiLineString(若只有一条则返回该条)
Geometry lineGeom = LineStringExtracter.getGeometry(gc);
System.out.println(lineGeom.getGeometryType()); // LineString
// 按类名提取
List<Geometry> onlyPoly = GeometryExtracter.extract(gc, "Polygon");
System.out.println(onlyPoly.size()); // 1
}
}
5.8.2 提取器的实现细节与性能
这些提取器内部都实现了 GeometryFilter 接口,过滤器的语义是”遍历整棵几何树,对每个组件回调一次”,因此它们的复杂度是 O(n) 且会深入到嵌套的 GeometryCollection 内部。这一点比自己写 for 循环 + instanceof 可靠,因为自己写很容易漏掉集合嵌套。
不过要注意两个细节。第一,PointExtracter 返回的 List 里装的是 Point 对象引用,不是副本;如果后续要修改,先 copy。第二,LineStringExtracter.getGeometry(Geometry) 会用工厂的 buildGeometry 重新装配,因此返回类型可能是 LineString 也可能是 MultiLineString,取决于数量,调用方不要硬转。
另外还有一个 ComponentCoordinateExtracter 与 LinearComponentExtracter 常被混淆:前者提取的是”每个组件的代表坐标”(用于快速定位),后者提取的是线性组件本身,而且多了一个 forceToLineString 选项,可以把 LinearRing 转成普通 LineString,方便参与那些不接受环的算法。
5.9 规范化与方向:normalize、reverse、copy
5.9.1 为什么需要 normalize
几何的 WKT 文本表示不唯一:同一个环可以从任意顶点开始、可以顺时针也可以逆时针;同一条线可以从任一端开始。这会让”看起来一样”的几何在文本比较、缓存键、去重时产生差异。normalize() 的作用就是给每个几何定义一个确定性的规范形式,使得 equalsNorm()、compareTo() 这样的比较有稳定结果。
JTS 的规范化规则(源码位于各子类的 normalize 实现中):
- Point 天然规范,normalize 什么都不做;
- LineString 比较首尾坐标,若首点大于尾点则整条反序(比较用 Coordinate.compareTo,先比 X 再比 Y);
- LinearRing 先 scroll 到最小坐标作为起点,再按方向要求决定是否反序;
- Polygon 的外环规范为顺时针(CW)、洞规范为逆时针(CCW),起点都在各自的最小坐标,洞之间再按比较器排序;
- Multi 与 Collection 对每个组件递归 normalize,再按组件排序。
5.9.2 normalize 会修改自身
这是 JTS 里少见的”可变”API:normalize() 返回 void 并原地修改;想要新对象请用 norm()。下面的例子同时演示 reverse 与 normalize 的效果。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;
public class NormalizeDemo {
public static void main(String[] args) throws Exception {
GeometryFactory gf = new GeometryFactory();
WKTReader reader = new WKTReader(gf);
// 逆时针的外环
Geometry a = reader.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
Geometry b = a.copy();
b.normalize();
System.out.println(a); // POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))
System.out.println(b); // POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0))
System.out.println(a.equalsExact(b)); // false,顶点顺序不同
// 规范之后拓扑相等判断不受顺序影响
System.out.println(a.equalsTopo(b)); // true
System.out.println(a.norm().equalsExact(b.norm())); // true
// 线的反向
Geometry line = reader.read("LINESTRING (5 5, 1 1)");
System.out.println(line.reverse()); // LINESTRING (1 1, 5 5)
System.out.println(line); // LINESTRING (5 5, 1 1),原对象不变
}
}
注意 reverse() 不是原地修改,它返回新几何;normalize() 才是原地修改。这两个方法的行为不对称,是 JTS 历史演进留下的痕迹,用的时候不要凭直觉。
5.9.3 环方向:isCCW 与 Orientation
方向判断的权威工具是 org.locationtech.jts.algorithm.Orientation,常用静态方法有 isCCW(CoordinateSequence)、isCCW(Coordinate[])、isCCW(Coordinate p0, Coordinate p1, Coordinate p2)。对于环,JTS 用带符号面积判断(源码里用更稳健的算法避免极端浮点误差):
import org.locationtech.jts.algorithm.Orientation;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LinearRing;
public class OrientationDemo {
public static void main(String[] args) {
GeometryFactory gf = new GeometryFactory();
LinearRing ccw = gf.createLinearRing(new Coordinate[] {
new Coordinate(0, 0), new Coordinate(10, 0),
new Coordinate(10, 10), new Coordinate(0, 10), new Coordinate(0, 0)
});
System.out.println(Orientation.isCCW(ccw.getCoordinateSequence())); // true
System.out.println(ccw.isCCW()); // true,环上的便捷方法
LinearRing cw = (LinearRing) ccw.reverse();
System.out.println(cw.isCCW()); // false
}
}
方向在 JTS 内部有强约定:Buffer 生成的外环是 CCW(正缓冲),Polygonizer 生成的外环是 CW,GeoJSON RFC 7946 要求外环 CCW。所以做格式转换时经常需要”逆时针化”,这正是第 07 章 GeoJsonWriter.setForceCCW 存在的原因。
5.10 AffineTransformation 基础:矩阵与工厂方法
5.10.1 矩阵模型
仿射变换在二维平面上可以用 3×3 齐次矩阵表示,JTS 只存前两行共 6 个有效元素:
| m00 m01 m02 |
T = | m10 m11 m12 |
| 0 0 1 |
x' = m00 * x + m01 * y + m02
y' = m10 * x + m11 * y + m12
m00/m01/m10/m11 决定旋转、缩放、错切与反射,m02/m12 决定平移。这也是 5.1 节强调”工厂不做舍入”的另一个侧面:AffineTransformation 只改 X、Y 两个 ordinate,Z、M 原样保留。
5.10.2 静态工厂方法一览
| 静态方法 | 效果 | 说明 |
|---|---|---|
| translationInstance(dx, dy) | 平移 | 最常用 |
| rotationInstance(theta) | 绕原点逆时针旋转 | theta 为弧度 |
| rotationInstance(theta, x, y) | 绕点 (x,y) 旋转 | 内部先平移到原点再转回 |
| rotationInstance(sinTheta, cosTheta) | 用 sin/cos 直接指定旋转 | 90 度整数倍时避免浮点误差 |
| scaleInstance(xScale, yScale) | 绕原点缩放 | 两轴可不同比例 |
| scaleInstance(xScale, yScale, x, y) | 绕点 (x,y) 缩放 | 常用于”以中心缩放” |
| shearInstance(xShear, yShear) | 错切 | 剪力系数 |
| reflectionInstance(x, y) | 沿过原点、方向向量 (x,y) 的直线反射 | |
| reflectionInstance(x0, y0, x1, y1) | 沿两点确定的直线反射 | 两点不能重合 |
除了静态方法,还有等价的”实例设置”方法 setToTranslation、setToRotation、setToScale、setToShear、setToReflection,以及”叠加式”方法 translate、rotate、scale、shear、reflect——后者会把本次变换组合到当前矩阵上(注意组合顺序,下一节详述)。
5.10.3 五种基本变换的可运行示例
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.util.AffineTransformation;
import org.locationtech.jts.io.WKTReader;
public class AffineBasic {
public static void main(String[] args) throws Exception {
Geometry line = new WKTReader().read("LINESTRING (0 0, 10 0)");
System.out.println(AffineTransformation.translationInstance(5, 3).transform(line));
// LINESTRING (5 3, 15 3)
System.out.println(AffineTransformation.scaleInstance(2, 2).transform(line));
// LINESTRING (0 0, 20 0)
// 绕原点逆时针 90 度:x' = -y, y' = x
System.out.println(AffineTransformation.rotationInstance(Math.PI / 2).transform(line));
// LINESTRING (0 0, 0 10)
// 用 sin/cos 精确表达 90 度,避免 Math.PI/2 的舍入误差
AffineTransformation rot90 = AffineTransformation.rotationInstance(1.0, 0.0);
System.out.println(rot90.transform(line)); // LINESTRING (0 0, 0 10)
// 绕点 (10, 0) 旋转 90 度
System.out.println(AffineTransformation.rotationInstance(Math.PI / 2, 10, 0).transform(line));
// LINESTRING (10 0, 10 10)
// 沿 X 轴反射:y 取反
System.out.println(AffineTransformation.reflectionInstance(0, 0, 1, 0).transform(line));
// LINESTRING (0 0, 10 0)
// 错切:y 不变,x 随 y 线性偏移
System.out.println(AffineTransformation.shearInstance(0.5, 0).transform(line));
// LINESTRING (0 0, 10 0)
}
}
这里要提醒一个 API 细节:Geometry.apply(CoordinateFilter) 的返回值是 void,它是”改自己”的接口,不能写成像 Builder 那样的链式调用。想把变换结果作为新对象拿到,统一用 AffineTransformation.transform(Geometry),它的实现就是 g.copy() 后对副本 apply(this),返回新几何、不改输入。
另外注意”绕原点旋转 90 度”的结果:LINESTRING (0 0, 10 0) 变成 (0 0, 0 10)。如果坐标系是屏幕坐标(Y 轴向下),视觉上就是顺时针——角度方向永远是数学意义的,视觉方向取决于坐标轴朝向,这是 GIS 新手最容易搞混的地方之一。
5.11 矩阵组合、逆变换与控制点拟合
5.11.1 compose 与 composeBefore 的顺序陷阱
矩阵乘法不满足交换律,所以”先平移再旋转”和”先旋转再平移”结果完全不同。JTS 用两个方法把顺序讲清楚:
- A.compose(B):结果是 T_B × T_A,效果等价于”先 A,后 B”;
- A.composeBefore(B):结果是 T_A × T_B,效果等价于”先 B,后 A”。
而实例方法 translate、rotate、scale、shear、reflect 内部都调用 compose,也就是”把新变换叠加在当前变换之后”。下面用坐标亲自验证一遍。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.util.AffineTransformation;
import org.locationtech.jts.io.WKTReader;
public class ComposeOrder {
public static void main(String[] args) throws Exception {
Coordinate origin = new Coordinate(0, 0);
// 先平移 (10,0),再旋转 90 度
AffineTransformation t1 = AffineTransformation.translationInstance(10, 0);
t1.rotate(Math.PI / 2);
System.out.println(t1.transform(origin, new Coordinate())); // (0, 10)
// 先旋转,再平移
AffineTransformation t2 = AffineTransformation.rotationInstance(Math.PI / 2);
t2.translate(10, 0);
System.out.println(t2.transform(origin, new Coordinate())); // (10, 0)
// compose 与 composeBefore 的等价写法
AffineTransformation a = AffineTransformation.translationInstance(10, 0);
AffineTransformation b = AffineTransformation.rotationInstance(Math.PI / 2);
System.out.println(a.copy().compose(b).transform(origin, new Coordinate())); // (0, 10)
System.out.println(a.copy().composeBefore(b).transform(origin, new Coordinate())); // (10, 0)
// 对几何做整体旋转,验证只改 X/Y
Geometry line = new WKTReader().read("LINESTRING (0 0, 4 0)");
AffineTransformation rot = AffineTransformation.rotationInstance(Math.PI / 4);
System.out.println(rot.transform(line));
// LINESTRING (0 0, 2.8284271247461903 2.8284271247461903)
}
}
记住一句话:实例方法链是”从后往前读”的——t.translate(…); t.rotate(…) 读作”先平移后旋转”,因为每步都把它追加到已有变换的右边(矩阵意义上)。
5.11.2 逆变换与行列式
getInverse() 用 3×3 矩阵求逆公式计算逆变换,行列式 getDeterminant() = m00m11 - m01m10。行列式为 0 表示变换把平面压成一条线或一个点,不可逆,此时抛 NoninvertibleTransformationException(继承自 RuntimeException)。常见的不可逆情形包括:X 或 Y 缩放为 0、两个控制点重合导致退化。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.util.AffineTransformation;
import org.locationtech.jts.geom.util.NoninvertibleTransformationException;
public class InverseDemo {
public static void main(String[] args) {
AffineTransformation t = AffineTransformation.translationInstance(10, 20)
.scale(2, 3); // 先平移再缩放:x' = 2*(x+10), y' = 3*(y+20)
System.out.println(t.getDeterminant()); // 6.0
AffineTransformation inv = t.getInverse();
Coordinate p = new Coordinate(4, 7);
Coordinate q = t.transform(p, new Coordinate());
Coordinate back = inv.transform(q, new Coordinate());
System.out.println(q); // (28.0, 81.0)
System.out.println(back); // (4.0, 7.0),回到原位
try {
AffineTransformation.scaleInstance(0, 1).getInverse();
} catch (NoninvertibleTransformationException e) {
System.out.println(e.getMessage()); // Transformation is non-invertible
}
}
}
5.11.3 用控制点拟合变换
实际工程里我们往往不知道矩阵,只知道”这几个点应该落到那几个点”。AffineTransformationFactory 提供了三种拟合方式:
| 方法 | 控制点个数 | 能表达的变换 | 说明 |
|---|---|---|---|
| createFromControlVectors(src0, dest0) | 1 | 纯平移 | 两点差即平移量 |
| createFromControlVectors(src0, src1, dest0, dest1) | 2 | 平移 + 旋转 + 等比缩放 | 无错切、无反射;退化时返回 null |
| createFromControlVectors(src0..src2, dest0..dest2) | 3 | 完整仿射 | 六个自由度全部确定 |
| createFromBaseLines(src0, src1, dest0, dest1) | 两条基线 | 平移 + 旋转 | 保持长度比例,适合”摆正”场景 |
注意两点:两控制点版本在源两点重合(距离为 0)时返回 null,而不是抛异常,调用方必须判空;三控制点版本不接受退化三角形,否则 AffineTransformationBuilder 求解失败。另外,旧的六参构造函数 AffineTransformation(Coordinate… dest2) 在当前源码里是 @deprecated 且直接抛 UnsupportedOperationException,网上很多老代码用它,迁移时必须换成工厂方法。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.util.AffineTransformation;
import org.locationtech.jts.geom.util.AffineTransformationFactory;
import org.locationtech.jts.io.WKTReader;
public class ControlPointFit {
public static void main(String[] args) throws Exception {
// 三个控制点:把单位正方形映射成一个平行四边形(含错切)
Coordinate s0 = new Coordinate(0, 0);
Coordinate s1 = new Coordinate(1, 0);
Coordinate s2 = new Coordinate(0, 1);
Coordinate d0 = new Coordinate(10, 10);
Coordinate d1 = new Coordinate(12, 10);
Coordinate d2 = new Coordinate(11, 13);
AffineTransformation fit = AffineTransformationFactory
.createFromControlVectors(s0, s1, s2, d0, d1, d2);
Geometry square = new WKTReader().read(
"POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))");
System.out.println(fit.transform(square));
// POLYGON ((10 10, 12 10, 13 13, 11 13, 10 10))
// 两控制点:平移 + 旋转 + 等比缩放
AffineTransformation twoPt = AffineTransformationFactory.createFromControlVectors(
new Coordinate(0, 0), new Coordinate(1, 0),
new Coordinate(0, 0), new Coordinate(0, 2));
System.out.println(twoPt.transform(new Coordinate(1, 0)));
// (0.0, 2.0)
// 退化输入返回 null
System.out.println(AffineTransformationFactory.createFromControlVectors(
new Coordinate(5, 5), new Coordinate(5, 5),
new Coordinate(0, 0), new Coordinate(1, 1))); // null
}
}
三控制点拟合的数学本质是解 6 个线性方程:把 (x,y) → (m00x+m01y+m02, m10x+m11y+m12) 的六个未知数用三组对应点解出。AffineTransformationBuilder 内部用克拉默法则或高斯消元实现,源码在 geom/util/AffineTransformationBuilder.java,想深入的同学可以直接读,代码不到 200 行。
5.12 仿射变换的工程细节与修复配合
5.12.1 transform(Geometry) 到底做了什么
源码只有三行:
Geometry g2 = g.copy();
g2.apply(this);
return g2;
也就是说:先深拷贝,再用自己(AffineTransformation 实现了 CoordinateSequenceFilter)遍历所有坐标序列,对每个点调用 transform(seq, i),只写 setOrdinate(i, 0/1),不碰 Z 与 M。因此:
- 输入几何不会被修改,返回的是新几何;
- SRID、UserData(copy 会保留引用)、工厂都随拷贝保留;
- Z/M 值原样保留,但如果变换带有”非等比缩放 + 旋转”,Z 与 XY 的几何关系已经不再一致,需要业务自己决定是否丢弃 Z;
- 空几何、空组件会被安全跳过。
5.12.2 误差、精度与 GeometryFixer
仿射变换几乎必然引入浮点误差:旋转 30 度后,本来共线的点可能变成”差一点点共线”,本来重合的端点可能差 1e-16。这在叠加分析、有效性校验时可能触发 TopologyException 或判为无效几何。工程上的处理套路是:
- 先用 AffineTransformation 变换;
- 用 PrecisionModel.makePrecise(Coordinate) 或 GeometryPrecisionReducer 把坐标吸附到目标网格;
- 用 GeometryFixer 修复可能出现的自相交、环塌缩、重复点;
- 最后再进入叠加/入库流程。
GeometryFixer 的 API 很简洁:静态 fix(Geometry)、fix(Geometry, boolean isKeepMulti),或者 new GeometryFixer(geom) 后调用 setKeepCollapsed(boolean)、setKeepMulti(boolean)、getResult()。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.geom.util.AffineTransformation;
import org.locationtech.jts.geom.util.GeometryFixer;
import org.locationtech.jts.io.WKTReader;
public class AffineFixDemo {
public static void main(String[] args) throws Exception {
GeometryFactory gf = new GeometryFactory(new PrecisionModel(1000), 0);
WKTReader reader = new WKTReader(gf);
// 一个带微小自相交的三角形(顶点几乎共线)
Geometry g = reader.read("POLYGON ((0 0, 10 0.0001, 10 0, 0 0.0001, 0 0))");
System.out.println(g.isValid()); // false
// 旋转 45 度后按毫米网格吸附
Geometry rotated = AffineTransformation.rotationInstance(Math.PI / 4).transform(g);
PrecisionModel pm = new PrecisionModel(1000);
Coordinate[] cs = rotated.getCoordinates();
for (Coordinate c : cs) {
pm.makePrecise(c); // 逐点吸附(注意:这不会自动重建几何)
}
System.out.println(rotated.getCoordinates()[1]);
// 真正修复要重建几何
Geometry fixed = GeometryFixer.fix(rotated);
System.out.println(fixed.isValid()); // true
System.out.println(fixed.getGeometryType()); // Polygon 或 MultiPolygon
}
}
这段代码里有一处必须点破的坑:直接改 rotated.getCoordinates() 返回的数组,对几何是否生效取决于内部序列实现。CoordinateArraySequence 的 getCoordinates() 返回的是坐标对象数组的引用,改 Coordinate 对象可能”碰巧”生效;PackedCoordinateSequence 返回的是新构造的 Coordinate 副本,改了完全无效。可靠做法永远是通过 GeometryEditor、GeometryTransformer 或重建几何来修改。
AffineTransformation 与 GeometryFixer 的组合在”栅格配准转矢量”“CAD 图纸摆正”“扫描件纠偏”这类场景非常常见:先用三控制点拟合仿射矩阵,再把整层要素一次性变换,最后统一做精度吸附与修复。
5.13 常见问题与避坑
5.13.1 环不闭合与”三点环”
问:为什么 createPolygon(new Coordinate[]{…}) 有时抛 IllegalArgumentException?
答:createPolygon(Coordinate[]) 内部会先 createLinearRing,而 LinearRing 强制要求首尾坐标相等(2D 比较)且点数不少于 3。忘记闭合是最常见原因;其次是从数据库或文本里读到的坐标本身就不闭合。稳妥写法是先用 CoordinateList 装点、调用 closeRing(),再建环。
另一个隐藏问题是”三点环”:A、B、A 这样的序列在构造期合法(长度 3 且闭合),但它把一条线折返回去,面积为 0,属于塌缩几何。它在 WKT 里看起来像 POLYGON,实际 isValid() 为 false。做数据质检时不能只判断”能不能构造”,还要跑 isValid()。
5.13.2 以为设了 PrecisionModel 就会自动取整
问:工厂设了 new PrecisionModel(1000),为什么坐标还是有一长串小数?
答:工厂不做舍入,Javadoc 明确说明它假定输入已满足精度。需要取整请显式调用 PrecisionModel.makePrecise,或用 GeometryPrecisionReducer。前者只改数值不改拓扑,可能制造无效几何;后者会在降精度后修复拓扑,更适合批量处理。
5.13.3 换工厂后坐标系没跟着变
问:用 createGeometry 换到 SRID=3857 的工厂,为什么还是不能和 3857 的数据叠加?
答:SRID 只是标签,不是投影引擎。换工厂只改标签,不改数值。真正的投影转换要用 Proj4J、GeoTools 或数据库函数,JTS 本身不提供。这一点在第 19 章会展开。
5.13.4 GeometryEditor 删点删出了无效几何
问:用 CoordinateOperation 过滤重复点后,偶尔抛异常或得到无效几何。
答:GeometryEditor 不校验结果合法性。对环来说,删点必须保证”闭合 + 至少 3 点”;对面来说,洞不能跑到外环之外。安全做法是”过滤后重建 + GeometryFixer 兜底”,或者改用 5.6 节的 GeometryTransformer,在更上层控制结构。
5.13.5 误用已废弃的重载与构造函数
问:编译时看到大量 deprecation 警告,甚至运行时报 UnsupportedOperationException。
答:本章涉及的三处典型废弃 API 是:createMultiPoint(Coordinate[])(改用 createMultiPointFromCoords)、AffineTransformation 的六参构造函数(改用 AffineTransformationFactory)、LinearRing 的 (Coordinate[], PrecisionModel, int) 构造函数(改用 GeometryFactory)。废弃不等于还能用,六参构造函数现在直接抛异常,这是”运行时才炸”的隐雷。
5.13.6 直接改 getCoordinates() 的返回值
问:改了 getCoordinates() 里的 Coordinate,为什么几何没变?
答:getCoordinates() 的语义是”导出一份坐标数组”,对 CoordinateArraySequence 它导出的是共享 Coordinate 对象,对 PackedCoordinateSequence 它每次 new 出新对象。行为取决于实现,绝对不能依赖。所有修改都应通过编辑器、变换器或重建几何完成。
5.13.7 旋转 90 度后出现 6.123e-17
问:旋转 90 度后坐标变成 1.2246e-16 这种极小数,导致后续拓扑判断错乱。
答:Math.PI/2 的正余弦不是精确值。90 度的整数倍旋转应使用 rotationInstance(sinTheta, cosTheta) 传精确的 0、1、-1;更一般地,变换完成后按目标精度 makePrecise 或交给 GeometryPrecisionReducer。
5.14 本章小结
本章从”造几何”讲到”改几何”再到”变换几何”,核心要点可以压缩成下面几条:
- GeometryFactory 的状态只有 PrecisionModel、SRID、CoordinateSequenceFactory 三项;默认坐标序列工厂是 CoordinateArraySequenceFactory,不是 Packed。
- 工厂只负责装配,不做精度舍入;需要网格化坐标必须显式调用 makePrecise 或 PrecisionReducer。
- createXxx 家族遵循”空构造 / 按坐标 / 按子几何”三种形态;createMultiPoint(Coordinate[]) 已废弃,改用 createMultiPointFromCoords。
- LinearRing 强制闭合且至少 3 点,环方向在构造期不限;Polygon 的外环与洞都是 LinearRing,洞可以为 null。
- 空几何优于 null:createEmpty(dimension) 与各无参 createXxx() 提供类型化的空值,便于下游统一处理。
- buildGeometry 自动选”最具体类型”但不拍平 Multi;GeometryCombiner 会先展开再归类,更适合合并分片结果。
- GeometryEditor 保持结构不变、只改坐标或删组件,不校验结果合法性;GeometryTransformer 允许改变结构,是降维、过滤、类型归并的正确工具。
- GeometryMapper.map 只映射第一层,flatMap 递归展开并拍平;提取器家族(Point/LineString/Polygon/GeometryExtracter)负责从混合集合里按类型捞取。
- normalize() 原地规范化(外环 CW、洞 CCW、起点取最小坐标),reverse()/copy() 返回新对象;方向判断用 Orientation.isCCW。
- AffineTransformation 用 3×3 矩阵的 6 个有效元素表达五种基本变换;compose 是”先自己后参数”,实例方法 translate/rotate 链等价于追加在右侧。
- 逆变换依赖行列式非零;三控制点可拟合完整仿射,两控制点只能表达平移 + 旋转 + 等比缩放,且退化时返回 null。
- 变换只改 X/Y,Z/M 原样保留;旋转带来的浮点误差应通过精度吸附 + GeometryFixer 修复后再进入分析流程。
下一章进入数据交换:WKT 与 WKB 的文本/二进制表示、读写器参数、字节布局与常见坑。本章的构造与编辑能力会在那里反复用到——WKTReader 解析出来的几何同样受工厂的 SRID 与精度模型影响,而 WKBWriter 的维度设置则直接决定坐标序列里哪些 ordinate 会被写出去。
相关资源
- GeometryFactory 源码:https://github.com/locationtech/jts/blob/master/modules/core/src/main/java/org/locationtech/jts/geom/GeometryFactory.java
- GeometryEditor 源码:https://github.com/locationtech/jts/blob/master/modules/core/src/main/java/org/locationtech/jts/geom/util/GeometryEditor.java
- GeometryTransformer 源码:https://github.com/locationtech/jts/blob/master/modules/core/src/main/java/org/locationtech/jts/geom/util/GeometryTransformer.java
- AffineTransformation 源码:https://github.com/locationtech/jts/blob/master/modules/core/src/main/java/org/locationtech/jts/geom/util/AffineTransformation.java
- GeometryFixer 源码:https://github.com/locationtech/jts/blob/master/modules/core/src/main/java/org/locationtech/jts/geom/util/GeometryFixer.java
- 官方 Javadoc:https://locationtech.github.io/jts/javadoc/
- 本系列第 04 章(几何对象模型):https://znlgis.github.io/gis/tutorial/jts/第04章-几何对象模型详解/
- 本系列第 06 章(WKT 与 WKB 序列化):https://znlgis.github.io/gis/tutorial/jts/第06章-WKT与WKB序列化/
- 本系列第 17 章(有效性与 Coverage 质检):https://znlgis.github.io/gis/tutorial/jts/第17章-有效性与Coverage质检/
- 本系列第 18 章(精度模型与数值鲁棒性):https://znlgis.github.io/gis/tutorial/jts/第18章-精度模型与数值鲁棒性/