第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 或判为无效几何。工程上的处理套路是:

  1. 先用 AffineTransformation 变换;
  2. 用 PrecisionModel.makePrecise(Coordinate) 或 GeometryPrecisionReducer 把坐标吸附到目标网格;
  3. 用 GeometryFixer 修复可能出现的自相交、环塌缩、重复点;
  4. 最后再进入叠加/入库流程。

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 本章小结

本章从”造几何”讲到”改几何”再到”变换几何”,核心要点可以压缩成下面几条:

  1. GeometryFactory 的状态只有 PrecisionModel、SRID、CoordinateSequenceFactory 三项;默认坐标序列工厂是 CoordinateArraySequenceFactory,不是 Packed。
  2. 工厂只负责装配,不做精度舍入;需要网格化坐标必须显式调用 makePrecise 或 PrecisionReducer。
  3. createXxx 家族遵循”空构造 / 按坐标 / 按子几何”三种形态;createMultiPoint(Coordinate[]) 已废弃,改用 createMultiPointFromCoords。
  4. LinearRing 强制闭合且至少 3 点,环方向在构造期不限;Polygon 的外环与洞都是 LinearRing,洞可以为 null。
  5. 空几何优于 null:createEmpty(dimension) 与各无参 createXxx() 提供类型化的空值,便于下游统一处理。
  6. buildGeometry 自动选”最具体类型”但不拍平 Multi;GeometryCombiner 会先展开再归类,更适合合并分片结果。
  7. GeometryEditor 保持结构不变、只改坐标或删组件,不校验结果合法性;GeometryTransformer 允许改变结构,是降维、过滤、类型归并的正确工具。
  8. GeometryMapper.map 只映射第一层,flatMap 递归展开并拍平;提取器家族(Point/LineString/Polygon/GeometryExtracter)负责从混合集合里按类型捞取。
  9. normalize() 原地规范化(外环 CW、洞 CCW、起点取最小坐标),reverse()/copy() 返回新对象;方向判断用 Orientation.isCCW。
  10. AffineTransformation 用 3×3 矩阵的 6 个有效元素表达五种基本变换;compose 是”先自己后参数”,实例方法 translate/rotate 链等价于追加在右侧。
  11. 逆变换依赖行列式非零;三控制点可拟合完整仿射,两控制点只能表达平移 + 旋转 + 等比缩放,且退化时返回 null。
  12. 变换只改 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章-精度模型与数值鲁棒性/