第18章 - 精度模型与数值鲁棒性
18.1 浮点数的物理现实
18.1.1 double 的精度预算
JTS 的所有坐标都是 Java double,遵循 IEEE-754 双精度:1 位符号、11 位指数、52 位显式尾数(加隐含位共 53 位有效二进制位)。这带来两个硬约束:
- 可精确表示的最大整数是 2^53 = 9,007,199,254,740,992,源码里对应 PrecisionModel.maximumPreciseValue;
- 十进制有效数字约 15.95 位,工程上按 15~16 位 估算。
关键在于:精度是相对的,不是绝对的。同样一个 double,数值越大,两个相邻可表示数之间的间隔(ulp,unit in the last place)越大。坐标 x 落在 [2^e, 2^(e+1)) 区间时,ulp = 2^(e-52)。
坐标量级 典型场景 ulp(约) 换算
────────────────────────────────────────────────────────────
1e2 度 经纬度(中国 73~135E) 1.4e-14 度 ≈ 1.6e-9 米
1e6 米 高斯投影 6 位坐标 1.2e-10 米 0.12 纳米
2e7 米 Web Mercator 全球 3.7e-9 米 3.7 纳米
3.85e7 米 带号前缀的 8 位坐标 7.5e-9 米 7.5 纳米
1e8 米 极端局部坐标 1.5e-8 米 15 纳米
单看 ulp,似乎精度绰绰有余:纳米级误差对 GIS 毫无影响。但 ulp 只是表示误差的下限,真正的麻烦来自运算中的误差放大。
18.1.2 灾难性抵消
两个相近的大数相减,有效数字会大量丢失。这不只是理论问题,在几何谓词里天天发生。判断三点方向(orientation)本质上是计算一个 2×2 行列式:
orient(p, q, r) = (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x)
如果 p、q、r 几乎共线,两个乘积都很大而结果接近 0,相减时高位全部抵消,剩下的低位由舍入误差主导,符号可能是错的。方向判断错一次,noding 的拓扑就错一处,叠加结果就可能出现环套环甚至死循环。
Java 里几个经典现象,说明了同一件事:
public class DoubleBasics {
public static void main(String[] args) {
System.out.println(0.1 + 0.2 == 0.3); // false
System.out.println(1e16 + 1.0 == 1e16); // true,1 被吞掉
System.out.println(1e16 + 1.0 - 1e16); // 0.0
System.out.println((0.1 + 0.2) - 0.3); // 5.55e-17
}
}
18.1.3 坐标单位决定了风险等级
同样是米,投影坐标的量级直接影响可用精度余量:
| 坐标体系 | 典型数量级 | 整数部分占用的十进制位 | 剩余小数位(约) |
|---|---|---|---|
| 经纬度(度) | 1e2 | 3 位 | 12 位,约 1e-10 度(≈ 1e-5 米) |
| 本地平面坐标(米) | 1e3~1e4 | 5 位 | 10 位,约 1e-6 米 |
| 高斯投影(米,不带号) | 1e6 | 7 位 | 8 位,约 1e-2 微米 |
| 带号前缀坐标(米) | 1e7~1e8 | 9 位 | 6 位,约 1 微米 |
表格里剩余小数位只是表示能力,运算还会继续吃掉余量。因此有一条工程经验:尽量让坐标靠近原点。局部坐标系、以图幅中心为原点的平移坐标,能显著降低 noding 的数值压力——第 19 章会给出平移的具体做法。
18.2 PrecisionModel:精度模型
18.2.1 三种类型
PrecisionModel(org.locationtech.jts.geom)描述允许的坐标网格。它有三种类型,用静态常量 PrecisionModel.FLOATING、FLOATING_SINGLE、FIXED 指定:
| 类型 | 含义 | getMaximumSignificantDigits() |
坐标是否被舍入 |
|---|---|---|---|
FLOATING |
完整双精度(默认) | 16 | 否 |
FLOATING_SINGLE |
单精度浮点 | 6 | 是(先转 float 再转回 double) |
FIXED |
固定网格 | 1 + ceil(log10(scale)) | 是(四舍五入到网格) |
FLOATING 是默认值:new GeometryFactory() 使用它,等价于不做精度管理,交给算法自己抗。FIXED 则是以精度换稳健:先把坐标钉在网格上,后续运算的输入就有确定的小数位,snap-rounding 可以保证 noding 的拓扑一致性。
18.2.2 scale 与 gridSize
FIXED 模型由 scale(比例因子) 定义。源码注释给出的映射关系是:
precise.x = round(input.x * scale) / scale
precise.y = round(input.y * scale) / scale
- scale = 1000:保留 3 位小数(毫米级,若坐标单位是米);
- scale = 0.001:舍入到最近的 1000(千米级);
- scale = -1000:JTS 1.19 起支持用负 scale 表示精确网格大小(版本历史 #804)。此时
gridSize()返回 1000,内部用Math.round(val / gridSize) * gridSize计算,比1/scale更准确、更稳健。
gridSize() 的语义要记牢:浮点模型返回 Double.NaN,固定模型返回网格边长。源码里 gridSize 字段为 0 时按 1.0/scale 计算,所以两者永远一致,只是负 scale 路径精度更好。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.PrecisionModel;
public class PrecisionModelDemo {
public static void main(String[] args) {
// 1) 浮点模型:不做任何舍入
PrecisionModel floating = new PrecisionModel(PrecisionModel.FLOATING);
System.out.println(floating.isFloating()); // true
System.out.println(floating.gridSize()); // NaN
System.out.println(floating.makePrecise(1.23456789)); // 1.23456789
// 2) 固定模型:保留 3 位小数
PrecisionModel pm1000 = new PrecisionModel(1000);
System.out.println(pm1000.getScale()); // 1000.0
System.out.println(pm1000.gridSize()); // 0.001
System.out.println(pm1000.makePrecise(1.23456789)); // 1.235
// 3) 负 scale:直接指定 1 米网格
PrecisionModel grid1m = new PrecisionModel(-1);
System.out.println(grid1m.getScale()); // 1.0
System.out.println(grid1m.gridSize()); // 1.0
System.out.println(grid1m.makePrecise(1.6)); // 2.0
System.out.println(grid1m.makePrecise(1.4)); // 1.0
// 4) 单精度模型
PrecisionModel single = new PrecisionModel(PrecisionModel.FLOATING_SINGLE);
System.out.println(single.makePrecise(1.23456789)); // 1.2345679
// 5) 对 Coordinate 就地舍入
Coordinate c = new Coordinate(1.23456789, 9.87654321);
pm1000.makePrecise(c);
System.out.println(c); // (1.235, 9.877, NaN)
}
}
18.2.3 舍入语义:不是四舍六入五成双
源码注释特别强调:PrecisionModel 使用非对称算术舍入(Asymmetric Arithmetic Rounding),不是 Java 的 Math.rint 银行家舍入。底层用的是 Math.round,即 floor(x + 0.5),正负数的 .5 都向正无穷方向靠:
import org.locationtech.jts.geom.PrecisionModel;
public class RoundingDemo {
public static void main(String[] args) {
PrecisionModel pm = new PrecisionModel(-1); // 1 米网格
System.out.println(pm.makePrecise(0.5)); // 1.0
System.out.println(pm.makePrecise(1.5)); // 2.0
System.out.println(pm.makePrecise(2.5)); // 3.0
System.out.println(pm.makePrecise(-0.5)); // 0.0
System.out.println(pm.makePrecise(-1.5)); // -1.0
System.out.println(pm.makePrecise(-2.5)); // -2.0
// NaN 与 Inf 原样返回
System.out.println(pm.makePrecise(Double.NaN)); // NaN
System.out.println(pm.makePrecise(Double.POSITIVE_INFINITY)); // Infinity
}
}
这个行为有两层影响:
- 确定性:同样的输入永远得到同样的网格坐标,适合做落库前的规范化;
- 不对称:坐标为负时,舍入方向与正坐标相反,可能让原本对齐的两条边在 0 附近一侧进、一侧退,反而制造 1 个网格的错位。这也是实践中推荐先平移,把坐标挪到正象限,再舍入的原因。
18.2.4 精度模型挂在哪里
PrecisionModel 属于 GeometryFactory,几何通过 getPrecisionModel() 暴露。注意:JTS 的精度模型是描述性的元数据,算法并不会因为几何挂了一个 FIXED 模型就自动 snap-rounding。真正决定 noding 策略的是运算时传入的 PrecisionModel 参数(例如 OverlayNG 的构造参数),而不是几何自带的那个。这一点极易混淆,18.5 节会展开。
18.3 GeometryPrecisionReducer:精度约简
18.3.1 两种约简模式
GeometryPrecisionReducer 提供两种截然不同的行为:
| 模式 | 入口 | 对多边形 | 对线/点 | 输出有效性 | 重复点 | 塌缩部件 |
|---|---|---|---|---|---|---|
| 拓扑约简(默认) | reduce(g, pm) |
snap-rounding,消重叠 | 逐点舍入 | 保证有效 | 移除 | 默认移除 |
| 逐点约简 | reducePointwise(g, pm) |
仅坐标舍入,可能自交 | 逐点舍入 | 可能无效 | 保留 | 保留 |
拓扑约简的多边形处理最终走 org.locationtech.jts.operation.overlayng.PrecisionReducer.reducePrecision:构造一个一元 OverlayNG,用目标精度模型做 snap-rounding,并把结果限制为面(setAreaResultOnly(true))。如果输入无效导致 noding 失败,会把 TopologyException 包装成 IllegalArgumentException(“Reduction failed, possible invalid input”) 抛出。
18.3.2 API 与参数
GeometryPrecisionReducer 的常用成员:
| 方法/参数 | 说明 |
|---|---|
GeometryPrecisionReducer(PrecisionModel pm) |
指定目标精度模型 |
reduce(Geometry g) |
按当前配置执行约简 |
setPointwise(boolean) |
切换为逐点模式 |
setChangePrecisionModel(boolean) |
是否把结果几何的 PM 元数据也改成目标 PM(默认 false) |
setRemoveCollapsedComponents(boolean) |
是否移除塌缩部件(默认 true) |
静态 reduce(g, pm) / reducePointwise(g, pm) / reduceKeepCollapsed(g, pm) |
三种快捷入口 |
默认不改 PM 元数据这一点要重点记住:约简后坐标已经是网格值,但 geom.getPrecisionModel() 仍可能是 FLOATING。若下游依赖 PM 判断这份数据是毫米级,必须显式 setChangePrecisionModel(true)。
18.3.3 完整示例
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.precision.GeometryPrecisionReducer;
public class PrecisionReduceDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry g = r.read(
"POLYGON ((0.123456 0.654321, 10.987654 0.111111,"
+ " 10.222222 10.333333, 0.444444 10.555555, 0.123456 0.654321))");
PrecisionModel pm = new PrecisionModel(-0.001); // 0.001 米网格
// 1) 拓扑约简:结果有效,坐标钉在网格上
Geometry topo = GeometryPrecisionReducer.reduce(g, pm);
System.out.println(topo.isValid()); // true
System.out.println(topo.getNumPoints()); // 5(重复点被移除)
System.out.println(topo);
// 2) 逐点约简:只改坐标,不做拓扑修复
Geometry pointwise = GeometryPrecisionReducer.reducePointwise(g, pm);
System.out.println(pointwise.isValid()); // 本例输入凸多边形,仍为 true
// 3) 改变精度模型元数据
GeometryPrecisionReducer reducer = new GeometryPrecisionReducer(pm);
reducer.setChangePrecisionModel(true);
Geometry withPm = reducer.reduce(g);
System.out.println(withPm.getPrecisionModel().getType()); // FIXED
// 4) 输入无效时拓扑约简可能抛异常
Geometry bad = r.read("POLYGON ((0 0, 10 10, 10 0, 0 10, 0 0))");
try {
GeometryPrecisionReducer.reduce(bad, new PrecisionModel(-0.1));
System.out.println("未抛异常:无效性可能被精度约简消除");
} catch (IllegalArgumentException ex) {
System.out.println("约简失败: " + ex.getMessage());
}
}
}
18.3.4 塌缩部件的处理
当一个面在目标精度下变薄为零(例如宽 0.4 毫米、网格 1 米),拓扑约简会把它删掉。若业务需要保留退化证据,用 reduceKeepCollapsed 或 setRemoveCollapsedComponents(false):
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.precision.GeometryPrecisionReducer;
public class CollapseDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry thin = r.read("POLYGON ((0 0, 10 0, 10 0.0004, 0 0.0004, 0 0))");
PrecisionModel pm = new PrecisionModel(-1); // 1 米网格
Geometry removed = GeometryPrecisionReducer.reduce(thin, pm);
System.out.println(removed.isEmpty()); // 通常为 true,塌缩部件被移除
Geometry kept = GeometryPrecisionReducer.reduceKeepCollapsed(thin, pm);
System.out.println(kept.isEmpty()); // 取决于输入与版本,可能为空
System.out.println(kept.getGeometryType()); // 可能保留为低维/退化几何
System.out.println(kept.isValid()); // 可能为 false(退化几何)
}
}
Javadoc 的措辞是 preserving collapsed linear elements。注意保留下来的退化几何可能无效(例如两点重合的 LineString),它是证据,不是可直接入库的数据。
18.3.5 逐点约简的适用场景
reducePointwise 只做坐标舍入,不重建拓扑,因此:
- 适合显示层:地图渲染只要坐标落在合理范围,轻微自交不影响视觉;
- 适合对拍分析:需要把两批数据舍入到同一网格再比较时,逐点更可控;
- 不适合入库:可能产生自相交、重复点、零长边,入库后别的系统会踩坑。
18.4 安全比例尺与 PrecisionUtil
18.4.1 为什么需要安全比例尺
OverlayNG 在固定精度下用 SnapRoundingNoder,它能保证 noding 的拓扑一致性,但有一个前提:精度不能超过 double 的计算能力。源码 Javadoc 的说法是精度限制在约 13 位十进制有效数字。若把 8 位整数部分的坐标保留 10 位小数(合计 18 位有效数字),snap-rounding 的 hot pixel 判定就会出错。
PrecisionUtil 提供了自动推算安全比例尺的工具,核心常量:
MAX_ROBUST_DP_DIGITS = 14:留给计算的十进制有效位数上限(小于 double 的 16 位,留余量);safeScale(double value)/safeScale(Geometry geom)/safeScale(Geometry a, Geometry b):按坐标最大量级推算安全 scale;inherentScale(...):按数据自身的小数位数推算能保住全部已有精度的 scale;robustScale(...)/robustPM(...):取固有比例尺与安全比例尺的较小者。
Javadoc 明确警告:inherentScale 与 robustScale 非常慢(需要把每个 ordinate 格式化成字符串数小数位),不推荐生产使用。生产上应当用 safeScale 的 O(1) 公式版本,或者直接依据数据规范写死网格。
18.4.2 安全比例尺的计算规则
safeScale 内部是 precisionScale(maxAbsOrdinate, 14):
magnitude = floor(log10(maxAbsOrdinate)) + 1 // 整数部分的十进制位数
precDigits = 14 - magnitude // 留给小数的位数
scale = 10 ^ precDigits
例如:
| 最大坐标绝对值 | magnitude | precDigits | safeScale | 等效网格 |
|---|---|---|---|---|
| 135(度) | 3 | 11 | 1e11 | 1e-11 度(≈ 1 微米) |
| 3,850,000(高斯 6 位) | 7 | 7 | 1e7 | 1e-7 米 |
| 38,500,000(带号前缀) | 8 | 6 | 1e6 | 1e-6 米 |
| 20,037,000(Web Mercator) | 8 | 6 | 1e6 | 1e-6 米 |
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.overlayng.PrecisionUtil;
public class PrecisionUtilDemo {
public static void main(String[] args) throws Exception {
System.out.println(PrecisionUtil.safeScale(135.0)); // 1.0E11
System.out.println(PrecisionUtil.safeScale(3.85e7)); // 1.0E6
System.out.println(PrecisionUtil.MAX_ROBUST_DP_DIGITS); // 14
WKTReader r = new WKTReader();
Geometry g = r.read("LINESTRING (38400000 2500000, 38410000 2501000)");
double scale = PrecisionUtil.safeScale(g);
PrecisionModel pm = new PrecisionModel(scale);
System.out.println(pm.getScale()); // 1.0E6
System.out.println(pm.gridSize()); // 1.0E-6
// 慢但能保住全部已有精度(仅演示,生产慎用)
double inherent = PrecisionUtil.inherentScale(g);
System.out.println(inherent); // 1.0(本例坐标只有整数)
}
}
18.4.3 选择网格的三条经验
- 以业务精度为准,不要以 double 上限为准:地籍 1:500 数据用 0.001 米网格足矣,没必要 1e-9;
- 网格要比最小要素尺寸小一个数量级:1 米网格配 3 米宽的地块会把地块抹掉;
- 同一图层统一网格:不同精度混存会让覆盖率永远无法通过(见第 17 章),也让 Union 结果不可预测。
18.5 OverlayNG 的精度模型与 snap-rounding
18.5.1 精度模型决定 noder
OverlayNG 的核心设计是:把“叠加计算”与“精度策略”解耦。算法本身只负责标注、选边、构环,而线段求交与打断(noding) 交给一个可插拔的 Noder,选择规则由精度模型决定:
| 精度模型 | 使用的 noder | 稳健性 | 输出坐标 |
|---|---|---|---|
| FLOATING | MCIndexNoder(外层包 ValidatingNoder 校验) | 不完全稳健,失败抛 TopologyException | 保持输入精度 |
| FIXED | SnapRoundingNoder | 精度不超过约 13 位有效数字时稳健 | 全部落在网格上 |
| 自定义 | 通过静态重载传入 Noder | 取决于 noder | 取决于 noder |
源码 Javadoc 明确写了:固定精度下用 SnapRoundingNoder 提供稳健计算(只要精度限制在约 13 位十进制有效数字内);浮点精度下用 MCIndexNoder,不完全稳健,可能抛 TopologyException,需要完全稳健的浮点叠加请用 OverlayNGRobust。
18.5.2 精度策略的三档选择
┌───────────────────────────────────────────────────────────┐
│ 档位 1:浮点叠加 OverlayNG.overlay(g0, g1, op) │
│ 最快、坐标改动最小;浮点 noder 不可靠时抛异常 │
│ 适用:数据本身干净、坐标量级正常、可接受偶发重试 │
├───────────────────────────────────────────────────────────┤
│ 档位 2:稳健叠加 OverlayNGRobust.overlay(g0, g1, op) │
│ 内部多级回退:浮点 → 吸附 → snap-rounding │
│ 适用:生产环境默认选择 │
├───────────────────────────────────────────────────────────┤
│ 档位 3:固定精度叠加 OverlayNG.overlay(g0, g1, op, pm) │
│ 主动把结果吸附到网格;坐标被修改、相邻要素可能合并 │
│ 适用:入库前规范化、需要结果可复现的批处理 │
└───────────────────────────────────────────────────────────┘
| 策略 | 速度 | 坐标改变 | 稳健性 | 典型场景 |
|---|---|---|---|---|
| 浮点 OverlayNG | 最快 | 最小 | 一般 | 交互式编辑、干净数据 |
| OverlayNGRobust | 较慢(失败才回退) | 小(吸附时略改) | 高 | 生产默认 |
| 固定精度 OverlayNG | 中等 | 全部落在网格 | 高 | 入库、批处理、对拍 |
| 旧引擎 SnapIfNeededOverlayOp | 慢 | 小 | 较高 | 兼容旧行为 |
18.5.3 OverlayNGRobust 的回退链
OverlayNGRobust.overlay 的策略是“先快后稳”,逐级降级,源码逻辑可以概括为:
overlay(g0, g1, op)
│
├─① 浮点叠加 OverlayNG.overlay(g0, g1, op)
│ 成功 → 返回(绝大多数情况走这里)
│ 失败(RuntimeException)→ 记住原始异常 exOriginal
│
├─② 吸附叠加,最多 5 轮(NUM_SNAP_TRIES = 5)
│ 初始吸附容差 tol = 坐标最大量级 / 1e12(SNAP_TOL_FACTOR)
│ 每轮:
│ a. 直接用 SnappingNoder(tol) 叠加
│ b. 失败则先对两个输入各自“自吸附”(self-snap),再叠加
│ 下一轮 tol = tol * 10
│
├─③ snap-rounding 叠加
│ 用 PrecisionUtil.safeScale(g0, g1) 生成安全比例尺
│ 以 FIXED 精度模型再跑 OverlayNG
│
└─④ 全部失败 → 抛出最初的 exOriginal
关键工程结论:OverlayNGRobust 的“稳健”是有代价的,只在浮点失败时才回退;一旦回退,坐标可能被吸附修改,结果与输入不再逐点一致。对精度敏感的业务(地籍面积)要记录是否发生了回退,必要时把该要素送人工复核。
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.overlayng.OverlayNG;
import org.locationtech.jts.operation.overlayng.OverlayNGRobust;
import org.locationtech.jts.operation.overlayng.PrecisionUtil;
public class OverlayPrecisionDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry a = r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
Geometry b = r.read("POLYGON ((5 -1, 15 -1, 15 5, 5 5, 5 -1))");
// 1) 纯浮点叠加(最常用)
Geometry g1 = OverlayNG.overlay(a, b, OverlayNG.INTERSECTION);
System.out.println(g1.getArea()); // 25.0
// 2) 固定精度叠加:结果吸附到 0.001 网格
PrecisionModel pm = new PrecisionModel(-0.001);
Geometry g2 = OverlayNG.overlay(a, b, OverlayNG.INTERSECTION, pm);
System.out.println(g2.getArea()); // 25.0(本例恰好不变)
// 3) 稳健叠加:生产推荐入口
Geometry g3 = OverlayNGRobust.overlay(a, b, OverlayNG.INTERSECTION);
System.out.println(g3.getArea()); // 25.0
// 4) 稳健判定比例尺(自动)
PrecisionModel auto = PrecisionUtil.robustPM(a, b);
System.out.println(auto.getScale());
// 5) 稳健一元合并
List<Geometry> parts = new ArrayList<Geometry>();
parts.add(a);
parts.add(b);
Geometry merged = OverlayNGRobust.union(parts);
System.out.println(merged.getArea()); // 175.0
}
}
18.5.4 系统属性 jts.overlay
Geometry.intersection/union/difference/symDifference 这些高层方法本身不直接暴露精度参数,它们通过内部的 GeometryOverlay 分派:
- 默认走旧引擎 SnapIfNeededOverlayOp(源码里
OVERLAY_NG_DEFAULT = false); - 启动时读取系统属性
jts.overlay:值为ng时改走 OverlayNGRobust;其它值(如old)保持旧引擎。
也就是说,在 JTS 1.21 快照中,高层 union() 默认仍不是 OverlayNG。要启用:
java -Djts.overlay=ng -cp jts-core.jar:app.jar com.example.Main
源码注释提醒:该属性只在类初始化时读取一次,不要在运行中动态切换,否则同一进程内的叠加行为可能不一致。另一个相关属性是 1.20 引入的 jts.relate:值为 ng 时,Geometry 的关系谓词走 RelateNG。
实践建议:需要精度控制时显式调用 OverlayNG / OverlayNGRobust API,不要依赖全局属性;全局属性适合“老系统整体升级引擎”的过渡期。
18.5.5 strict mode:让结果可链式处理
OverlayNG 默认是非严格模式,即“原始 JTS 叠加语义”:交集结果可能包含低维元素(面与面求交产生线、线求交产生点),面拓扑塌缩也可能输出线。setStrictMode(true) 会:
- 丢弃拓扑塌缩产生的线/点;
- 交集、差集结果只含与输入最高维一致的元素(同质);
- 并集、对称差在输入同维时同质。
好处是结果可以连续链式叠加而不必先清理低维碎片;代价是丢失部分信息。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.overlayng.OverlayNG;
public class StrictModeDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry a = r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
// 一条穿过面的线:面与面求交时,线不属于交集;
// 但面与线求交的非严格结果会保留线
Geometry line = r.read("LINESTRING (-5 5, 15 5)");
Geometry normal = OverlayNG.overlay(a, line, OverlayNG.INTERSECTION);
System.out.println(normal.getGeometryType()); // LineString(线在面内部的部分)
OverlayNG ov = new OverlayNG(a, line, new PrecisionModel(-0.001),
OverlayNG.INTERSECTION);
ov.setStrictMode(true);
Geometry strict = ov.getResult();
System.out.println(strict.getGeometryType());
// 输入维度不同时,strict 仍保留低维交集;同维时才会丢弃塌缩线
}
}
18.5.6 调试开关
OverlayNG 实例还提供几个仅用于调试的输出开关:
| 方法 | 输出 |
|---|---|
setOutputEdges(boolean) |
输出结果中的边 |
setOutputNodedEdges(boolean) |
输出 noding 后的边 |
setOutputResultEdges(boolean) |
输出构成结果的边 |
setOptimized(boolean) |
关闭优化(例如包络裁剪),用于排查优化引入的差异 |
这些开关会把内部结构作为结果返回,只适合在测试或问题复现中临时打开,不能用于生产。
18.6 TopologyException 与 noding failure 排查
18.6.1 异常的定义与信息
TopologyException 表示“处理过程中遇到无效或不一致的拓扑情况”。它有两个构造器:
TopologyException(String msg);TopologyException(String msg, Coordinate pt):会把坐标拼进消息,格式为msg + " [ " + pt + " ]",并提供getCoordinate()。
典型消息长这样:
com.locationtech.jts.geom.TopologyException: found non-noded intersection between
LINESTRING (10 0, 10 10) and LINESTRING (10 10, 10 0) [ (10, 10, NaN) ]
“found non-noded intersection”是最常见的 noding 失败形态:两条线段在数值上相交,但交点没有被正确插入,导致后续构环时拓扑不一致。括号里的坐标是发现问题的大致位置,对定位要素非常有用。
18.6.2 原因分类
| 类别 | 具体表现 | 判定方法 | 处理 |
|---|---|---|---|
| 输入无效 | 自相交、面重叠、环未闭合 | IsValidOp | GeometryFixer 修复后再算 |
| 精确重复 | 两条完全相同的线段、重复环 | RepeatedPointTester、WKT 对比 | 去重、Union 前先合并 |
| 近重合 | 顶点间距小于浮点分辨率 | 放大局部坐标检查 | 吸附到网格(固定精度) |
| 坐标量级过大 | 8 位整数 + 高精度小数 | 查看包络量级 | 平移 + 安全比例尺 |
| 退化元素 | 零长度边、少于 2 点的线 | IsValidOp(错误码 9/11) | 删除退化元素 |
| 非法集合 | 非空 GeometryCollection 参与 union/difference | 文档限制 | 先展平为同类多几何 |
| 精度模型不匹配 | 输入 FIXED、运算按 FLOATING 跑 | 检查 factory PM 与运算参数 | 显式传 PrecisionModel |
18.6.3 排查流程
捕获 TopologyException
│
▼
记录异常坐标 ex.getCoordinate() 与两条输入 WKT(或几何指纹)
│
▼
IsValidOp 分别校验 g0、g1 ── 无效 ──► GeometryFixer 修复 → 重算
│ 有效
▼
检查是否含重复点/重复线段 ── 是 ──► 去重(removeRepeatedPoints)/ 先 Union 自身
│ 否
▼
用安全比例尺重试固定精度叠加 ── 成功 ──► 记录“发生精度回退”,结果复核
│ 失败
▼
OverlayNGRobust 重试 ── 成功 ──► 记录并复核
│ 失败
▼
预处理:GeometryFixer + 吸附到网格 + 拆分大几何 → 再算
│
▼
仍失败:隔离该要素对,人工核查(通常能看到微小重叠或近重合边)
18.6.4 处理模板
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.geom.TopologyException;
import org.locationtech.jts.io.WKTWriter;
import org.locationtech.jts.operation.overlayng.OverlayNG;
import org.locationtech.jts.operation.overlayng.OverlayNGRobust;
import org.locationtech.jts.operation.overlayng.PrecisionUtil;
public class RobustOverlay {
public static Geometry overlay(Geometry g0, Geometry g1, int opCode) {
try {
return OverlayNG.overlay(g0, g1, opCode);
} catch (TopologyException ex) {
Coordinate pt = ex.getCoordinate();
System.out.println("浮点叠加失败,位置=" + pt + ",消息=" + ex.getMessage());
// 第一级回退:固定精度 + snap-rounding
try {
PrecisionModel pm = new PrecisionModel(PrecisionUtil.safeScale(g0, g1));
return OverlayNG.overlay(g0, g1, opCode, pm);
} catch (RuntimeException ex2) {
// 第二级回退:完整稳健链(吸附 + snap-rounding)
try {
return OverlayNGRobust.overlay(g0, g1, opCode);
} catch (TopologyException ex3) {
WKTWriter w = new WKTWriter();
System.out.println("g0 = " + w.write(g0));
System.out.println("g1 = " + w.write(g1));
throw ex3;
}
}
}
}
}
18.6.5 三个错误做法
- catch 后返回空几何:调用方以为“没有交集”,实际是计算失败,静默产生业务错误;
- 无上限重试:同样的输入重试一万次也不会变好,只会拖垮线程池;
- 用
buffer(0)修复一切:buffer(0) 能处理部分自相交,但会改变几何(可能吞掉小部件),且对近重合边无效。正确顺序是“先校验、后修复、再计算”。
18.7 精确算术:DD、CGAlgorithmsDD、RobustLineIntersector
18.7.1 浮点滤波 + 精确回退
JTS 处理方向判断的标准模式是两级策略:
orientationIndex(p1, p2, q)
│
▼
orientationIndexFilter(...) ← 只用普通 double 运算
│
├── 结果明确(1 / -1 / 0)→ 直接返回(绝大多数情况)
└── 结果不可靠(返回 >1)→ 用 DD 精确计算 2×2 行列式符号
滤波器的理论基础是 Shewchuk 的浮点误差分析,源码中的安全系数是 DP_SAFE_EPSILON = 1e-15。这意味着:JTS 的谓词在需要时是精确的,而不是“近似判断”。
18.7.2 DD:双精度双倍算术
DD(org.locationtech.jts.math)用两个 double 表示一个高精度数(hi + lo),有效精度约 106 位二进制 ≈ 31~32 位十进制,源码常量 EPS = 2^-106 ≈ 1.23259516440783e-32。它提供四则运算、开方、幂、取整、比较、行列式等能力。
| 方法 | 说明 |
|---|---|
DD.valueOf(double) / DD.valueOf(String) |
构造 |
add / subtract / multiply / divide |
返回新对象 |
selfAdd / selfSubtract / selfMultiply / selfDivide |
原地运算,返回自身,减少分配 |
signum() |
符号 |
doubleValue() |
转回 double(可能丢精度) |
determinant(x1, y1, x2, y2) |
2×2 行列式(静态) |
compareTo(DD) / equals(Object) |
比较 |
sqrt() / pow(int) |
开方与整数幂 |
isNaN() |
是否 NaN |
import org.locationtech.jts.algorithm.CGAlgorithmsDD;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.math.DD;
public class DDDemo {
public static void main(String[] args) {
// 1) 普通 double 会丢掉 1,DD 不会
double d = 1e16;
System.out.println(d + 1.0 - d); // 0.0
DD exact = DD.valueOf(d).add(1.0).subtract(d);
System.out.println(exact); // 1
System.out.println(exact.doubleValue()); // 1.0
// 2) 行列式符号
DD det = DD.determinant(1.0, 2.0, 2.0, 4.0);
System.out.println(det.signum()); // 0(共线)
// 3) 方向判断:使用 DD 版本
Coordinate p1 = new Coordinate(0, 0);
Coordinate p2 = new Coordinate(10, 10);
Coordinate q = new Coordinate(5, 5.000000000000001);
System.out.println(CGAlgorithmsDD.orientationIndex(p1, p2, q)); // 1
// 4) 精确交点(两条线平行时返回 null)
Coordinate ip = CGAlgorithmsDD.intersection(
new Coordinate(0, 0), new Coordinate(10, 10),
new Coordinate(0, 10), new Coordinate(10, 0));
System.out.println(ip); // (5, 5, NaN)
}
}
18.7.3 RobustLineIntersector
LineIntersector 是线段求交的基类,公开 API 包括:
| 方法 | 说明 |
|---|---|
computeIntersection(p, p1, p2) |
点与线段(用于点在线上判断) |
computeIntersection(p1, p2, q1, q2) |
两条线段求交 |
hasIntersection() |
是否相交 |
getIntersectionNum() |
交点个数(0/1/2) |
getIntersection(i) |
第 i 个交点坐标 |
isProper() |
是否为“真交”(交点不在端点) |
isInteriorIntersection() |
交点是否在某条线的内部 |
getEdgeDistance(segmentIndex, intIndex) |
交点到线段起点的距离 |
RobustLineIntersector 是它的稳健实现,内部用 CGAlgorithmsDD 做方向判断,能正确处理共线重叠、端点接触、近共线等边界情形。注意:它的“稳健”指拓扑判定正确,输出的交点坐标本身仍是 double,仍受舍入影响。
import org.locationtech.jts.algorithm.RobustLineIntersector;
import org.locationtech.jts.geom.Coordinate;
public class IntersectorDemo {
public static void main(String[] args) {
RobustLineIntersector li = new RobustLineIntersector();
// 1) 正常相交
li.computeIntersection(new Coordinate(0, 0), new Coordinate(10, 10),
new Coordinate(0, 10), new Coordinate(10, 0));
System.out.println(li.hasIntersection()); // true
System.out.println(li.getIntersectionNum()); // 1
System.out.println(li.getIntersection(0)); // (5, 5, NaN)
System.out.println(li.isProper()); // true
// 2) 共线重叠:交点数为 2
li.computeIntersection(new Coordinate(0, 0), new Coordinate(10, 0),
new Coordinate(5, 0), new Coordinate(15, 0));
System.out.println(li.getIntersectionNum()); // 2
System.out.println(li.isProper()); // false
// 3) 端点接触
li.computeIntersection(new Coordinate(0, 0), new Coordinate(10, 0),
new Coordinate(10, 0), new Coordinate(10, 10));
System.out.println(li.getIntersectionNum()); // 1
System.out.println(li.isInteriorIntersection()); // false
}
}
18.7.4 RobustDeterminant 与历史包袱
RobustDeterminant 是 Olivier Devillers 的 2×2 行列式符号算法的直接移植,历史上是 JTS 稳健性的基石。1.15 重构后,版本历史明确建议改用 CGAlgorithmsDD,RobustDeterminant 仅作为兼容保留。新代码不要再用它。
18.7.5 精确算术解决不了什么
精确谓词只保证“每一步判断正确”,但叠加分析还有两个不精确环节:
- 交点坐标仍要舍入成 double:精确求出的交点写回坐标数组时被舍入,可能落到某条线段的“错误一侧”,后续判断就会矛盾;
- 拓扑重建依赖全局一致性:构环、标注、合并是全局过程,局部谓词正确不代表全局拓扑一致。
这正是为什么 JTS 需要 snap-rounding、ValidatingNoder 和 OverlayNGRobust:精确算术是地基,不是全部。
18.8 CommonBitsOp、CommonBitsRemover 与 EnhancedPrecisionOp
18.8.1 Common Bits 原理
两个坐标如果量级相同,它们的 IEEE 表示往往共享大量高位。CommonBits 的思想是:把两个几何共有的高位比特去掉,让坐标变到原点附近,再执行运算,最后把公共位加回去。这样能显著降低抵消误差。
原始坐标:(38400000.123, 2500000.456)
(38400000.789, 2500001.012)
│
▼ 去掉公共位(相当于减去一个公共基准)
归零坐标:(0.123, 0.456)
(0.789, 1.012)
│
▼ 在原点附近做叠加/缓冲(数值误差小)
运算结果:( ..., ... )
│
▼ 加回公共位
最终坐标:(38400000.x, 2500000.y)
18.8.2 API 与示例
| 类/方法 | 说明 |
|---|---|
CommonBitsRemover.add(Geometry) |
登记几何,累积公共位 |
CommonBitsRemover.getCommonCoordinate() |
公共坐标(可理解为基准偏移) |
CommonBitsRemover.removeCommonBits(Geometry) |
返回去掉公共位后的副本 |
CommonBitsRemover.addCommonBits(Geometry) |
把公共位加回(就地修改) |
CommonBitsOp(boolean returnToOriginalPrecision) |
运算封装;参数决定是否回移 |
CommonBitsOp.intersection/union/difference/symDifference/buffer |
对应运算 |
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.precision.CommonBitsOp;
import org.locationtech.jts.precision.CommonBitsRemover;
public class CommonBitsDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry a = r.read("POLYGON ((10000000.1 20000000.1,"
+ " 10000010.1 20000000.1, 10000010.1 20000010.1,"
+ " 10000000.1 20000010.1, 10000000.1 20000000.1))");
Geometry b = r.read("POLYGON ((10000005.05 20000005.05,"
+ " 10000015.05 20000005.05, 10000015.05 20000015.05,"
+ " 10000005.05 20000015.05, 10000005.05 20000005.05))");
// 1) 直接看公共位
CommonBitsRemover remover = new CommonBitsRemover();
remover.add(a);
remover.add(b);
Coordinate common = remover.getCommonCoordinate();
System.out.println("公共基准 = " + common);
// 2) 用 CommonBitsOp 做交集
CommonBitsOp op = new CommonBitsOp(true); // true = 结果回移到原精度
Geometry inter = op.intersection(a, b);
System.out.println(inter.getArea()); // 约 25
// 3) 回移后的结果不做舍入,可能无效,必须检查
System.out.println(inter.isValid()); // true(本例简单,通常有效)
}
}
Javadoc 特别提醒:CommonBitsOp 回移结果时不做舍入,因此返回几何可能无效,调用方必须自行校验。
18.8.3 EnhancedPrecisionOp
EnhancedPrecisionOp 是对上面机制的“自动重试”封装:先正常算,抛 RuntimeException 再用 CommonBitsOp(true) 重算,并检查结果有效;无效或再失败则抛出原始异常。它提供 intersection/union/difference/symDifference/buffer 五个静态方法。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.precision.EnhancedPrecisionOp;
public class EnhancedPrecisionDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry a = r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
Geometry b = r.read("POLYGON ((5 5, 15 5, 15 15, 5 15, 5 5))");
// 与普通 intersection 同签名,内部在失败时用增强精度重试
Geometry inter = EnhancedPrecisionOp.intersection(a, b);
System.out.println(inter.getArea()); // 25.0
}
}
18.8.4 今天还值得用吗
结论:一般不建议新代码使用。
- OverlayNGRobust 的回退策略比 CommonBits 更全面(吸附 + snap-rounding),且是 1.18 之后的官方推荐;
- CommonBitsOp 回移不正确时会返回无效几何,需要调用方兜底;
- EnhancedPrecisionOp 吞掉原始异常再重抛的语义,在日志与监控上不够透明。
它们仍有价值的地方:维护老代码、对比“新旧精度策略”的差异、以及在极少量固定场景中作为最后兜底。buffer 相关的 EnhancedPrecisionOp.buffer 在 Javadoc 里也注明“已不再必要,因为 buffer 算法现在高度稳健”。
18.9 NaN、Inf 与非法数值
18.9.1 非法数值从哪里来
NaN 与 Infinity 不是抽象概念,在 GIS 生产链路上有明确的来源:
| 来源 | 例子 | 进入几何的方式 |
|---|---|---|
| 除零 | 计算坡度、比例时分母为 0 | 结果写入坐标 |
| 无解运算 | Math.sqrt(-1)、Math.log(-1) |
结果写入坐标 |
| 投影失败 | 投影参数错误、点不在投影域内 | 转换库返回 NaN |
| 空值填充 | 用 -9999 或 NaN 表示缺失 | 直接构造 Coordinate |
| 序列化损坏 | 二进制错位、文本截断 | WKT/WKB 解析 |
| 溢出 | 极大坐标相乘 | 中间结果 Inf |
18.9.2 检查手段
JTS 提供两级检查:
- IsValidOp.isValid(Coordinate):静态方法,明确检查 x、y 是否为 NaN 或 Infinite;
- Coordinate.isValid():实例方法,同样只看 x、y,不检查 z、m。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.operation.valid.IsValidOp;
public class InvalidOrdinateDemo {
public static void main(String[] args) {
Coordinate ok = new Coordinate(116.4, 39.9);
Coordinate nan = new Coordinate(Double.NaN, 39.9);
Coordinate inf = new Coordinate(116.4, Double.POSITIVE_INFINITY);
Coordinate badZ = new Coordinate(116.4, 39.9, Double.NaN);
System.out.println(IsValidOp.isValid(ok)); // true
System.out.println(IsValidOp.isValid(nan)); // false
System.out.println(IsValidOp.isValid(inf)); // false
System.out.println(badZ.isValid()); // true:z 为 NaN 不影响判定
}
}
注意最后一行:z、m 非法不会被判为无效。如果业务依赖高程,需要自己检查 coordinate.getZ()。
18.9.3 WKT 中的 Inf:1.21 的变化
JTS 1.21 起,WKTReader 支持读取 Inf 与 -Inf(大小写不敏感),版本历史条目为 #1166、#1167。这使得下面这类 WKT 可以成功解析并构造几何:
POINT (Inf NaN)
POINT (Inf -Inf)
LINESTRING (0 0, Inf 10)
配套地,OrdinateFormat 定义了三个特殊值文本:REP_POS_INF = "Inf"、REP_NEG_INF = "-Inf"、REP_NAN = "NaN"。这意味着“解析成功”不再等于“坐标合法”——1.21 之后的解析层必须显式做有限性校验,否则非法坐标会一路流进算法层,最终在某次 Envelope 计算或 noding 中表现为莫名其妙的结果或异常。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
public class WktInfDemo {
public static void main(String[] args) throws Exception {
WKTReader reader = new WKTReader();
// JTS 1.21 起可解析;1.20 及以前会抛 ParseException
Geometry g = reader.read("LINESTRING (0 0, Inf 10)");
System.out.println(g.getNumPoints()); // 2
System.out.println(g.isValid()); // false(错误码 10)
System.out.println(g.getEnvelopeInternal());
// Envelope 可能包含 Infinity,后续求交/索引行为不可依赖
}
}
18.9.4 GeometryFixer 清理非法坐标
GeometryFixer 的第一条语义规则就是“x 或 y 非有限的顶点被删除”。底层调用 CoordinateArrays.removeRepeatedOrInvalidPoints。这提供了一条确定的清洗路径:
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.util.GeometryFixer;
public class CleanInvalidDemo {
public static void main(String[] args) {
GeometryFactory gf = new GeometryFactory();
LineString dirty = gf.createLineString(new Coordinate[] {
new Coordinate(0, 0),
new Coordinate(Double.NaN, 5),
new Coordinate(10, 10)
});
System.out.println(dirty.getNumPoints()); // 3
Geometry cleaned = GeometryFixer.fix(dirty);
System.out.println(cleaned.getNumPoints()); // 2
System.out.println(cleaned.isValid()); // true
}
}
工程上建议把清洗放在入库入口,而不是散落在各个算法调用前。
18.9.5 防御性校验模板
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryCollection;
public final class OrdinateCheck {
private OrdinateCheck() {}
public static boolean allFinite(Geometry g) {
if (g.isEmpty()) return true;
if (g instanceof GeometryCollection) {
for (int i = 0; i < g.getNumGeometries(); i++) {
if (!allFinite(g.getGeometryN(i))) return false;
}
return true;
}
CoordinateSequence seq = g.getCoordinateSequence();
for (int i = 0; i < seq.size(); i++) {
double x = seq.getX(i);
double y = seq.getY(i);
if (Double.isNaN(x) || Double.isInfinite(x)) return false;
if (Double.isNaN(y) || Double.isInfinite(y)) return false;
}
return true;
}
public static void main(String[] args) {
System.out.println(allFinite(new Coordinate(1, 2)));
// 注意:Coordinate 不是 Geometry,这里仅演示方法骨架
}
}
18.10 容差设计原则
18.10.1 三类容差不要混用
工程上“容差”一词被滥用,至少要区分三类:
| 类型 | 含义 | JTS 中的载体 | 单位 |
|---|---|---|---|
| 网格容差 | 坐标落在多大网格上 | PrecisionModel(scale / gridSize) | 坐标单位 |
| 吸附容差 | 多近的点/线被认为同一点 | SnappingNoder、CoverageCleaner.setSnappingDistance | 坐标单位 |
| 业务阈值 | 多大缝隙算错误、面积变化多少算异常 | CoverageValidator.setGapWidth、自定义规则 | 业务单位(米/平方米) |
把三者混为一谈的典型后果:用“业务缝隙阈值 0.05 米”去当吸附距离,结果把小地块吸没了;或者把网格设得比缝隙阈值还大,导致缝隙被“吸附消失”,覆盖率假通过。
18.10.2 容差必须带单位意识
经纬度数据上,1 度约 111 公里,因此“0.001”在米制里是毫米,在度制里是 111 米。换算关系(球面近似):
| 容差 | 纬度方向 | 经度方向(北纬 39 度) | 备注 |
|---|---|---|---|
| 0.000001 度 | 0.111 米 | 0.0865 米 | 约 1e-6 度 |
| 0.0000001 度 | 1.11 厘米 | 0.87 厘米 | 亚分米级 |
| 1e-9 度 | 0.111 毫米 | 0.087 毫米 | 接近表示极限 |
| 1 秒(1/3600 度) | 30.9 米 | 24.0 米 | 传统制图单位 |
所以:米制阈值绝对不能直接套到经纬度数据上。要么先把数据投影到米制,要么把阈值换算成度(且经度方向随纬度变化,跨纬度大范围数据无法用单一换算系数)。
18.10.3 容差的层级关系
推荐的数量级关系:
网格容差 << 吸附容差 << 业务阈值 << 最小要素尺寸
0.001 米 0.005 米 0.05 米 0.5 米
│ │ │ │
│ │ │ └─ 小于它就该合并/删除
│ │ └─ 缝隙/重叠判定线
│ └─ 清理时的吸附距离
└─ 落库精度(PrecisionModel)
举例(1:500 地籍):网格 0.001 米,吸附 0.005 米,缝隙阈值 0.05 米,最小地块 0.5 米。若把网格设成 0.1 米,0.5 米宽的地块只剩 5 个格点,形状严重失真;若把吸附设成 0.05 米,相邻地块的公共边可能被整体拉偏。
18.10.4 容差在流水线中的传播
容差不是“只在一处设定”,它会沿流水线传播并叠加:
- 投影转换引入的误差(约毫米级);
- 数据采集误差(厘米到米级);
- 存储精度(字段小数位);
- 每次 buffer / snap / 精度约简引入的偏移;
- 面积计算对边界扰动的放大。
因此设计容差时要回答三个问题:
- 数据最终要满足什么精度规范(例如地籍界址点 ±5 厘米)?
- 哪些环节会消耗误差预算?各消耗多少?
- 出问题时如何判定是哪一环节的责任?
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.io.WKTReader;
import org.locationtech.jts.operation.overlayng.PrecisionUtil;
import org.locationtech.jts.precision.GeometryPrecisionReducer;
public class ToleranceDesignDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry g = r.read("LINESTRING (0.0001 0.0002, 10.0003 10.0004)");
// 经纬度数据:先算安全比例尺,避免“凭感觉”设网格
double safe = PrecisionUtil.safeScale(g);
System.out.println(safe); // 1.0E11(度制下可取到 1e-11 度)
// 业务需要的是 1e-7 度(约 1 厘米),用 precisionModel(-1e-7) 表达网格
PrecisionModel pm = new PrecisionModel(-1.0e-7);
Geometry snapped = GeometryPrecisionReducer.reducePointwise(g, pm);
System.out.println(snapped);
// 大坐标数据:先平移到局部原点,再舍入(减少高位占用)
AffineTransformation shift =
AffineTransformation.translationInstance(-38400000.0, -2500000.0);
Geometry local = shift.transform(g);
System.out.println(local);
}
}
18.10.5 面积精度的额外提醒
Geometry.getArea() 是对多边形内部做三角分解再求和,结果依赖边界坐标。对成千上万个要素求和时,double 累加本身也会积累误差(大数吃小数)。如果业务对总面积有严格精度要求(例如国土调查的面积汇总),建议:
- 用 DD 做累加,或
- 按行政区/图幅分组求和小计,再汇总小计,减少量级差,或
- 直接依赖数据库的 numeric 类型做汇总。
18.11 生产环境鲁棒性清单
18.11.1 分层清单
| 层次 | 检查项 | 推荐做法 |
|---|---|---|
| 输入 | 坐标有限 | IsValidOp.isValid(Coordinate) / allFinite 扫描 |
| 输入 | 环闭合、点数足够 | IsValidOp(错误码 9/11) |
| 输入 | 是否存在重复环/重复点 | RepeatedPointTester、WKT 去重 |
| 输入 | 几何类型与 SRID 一致 | 业务校验(见第 19 章) |
| 输入 | 空几何比例 | 统计空值,单独处理 |
| 运算 | 是否显式指定精度 | 固定精度 OverlayNG 或 OverlayNGRobust |
| 运算 | 是否有非空 GeometryCollection 参与 overlay | 先展平 |
| 运算 | 是否在循环里反复 reduce/validate | 只做一次,缓存结果 |
| 输出 | 结果是否 isValid | 抽查 + 关键链路全检 |
| 输出 | 面积/部件数变化 | 设阈值,超限人工复核 |
| 输出 | 是否发生精度回退 | 记录回退标记,进复核队列 |
| 运维 | JTS 版本是否锁定 | 锁定并纳入回归测试 |
| 运维 | TopologyException 率 | 监控指标,按要素类型分布 |
| 运维 | 金标准数据集 | 升级前后对比错误码与面积分布 |
18.11.2 反模式清单
| 反模式 | 为什么错 | 替代方案 |
|---|---|---|
用 == 比较坐标 |
浮点相等几乎不可靠 | compareTo、equals2D、或显式容差 |
| 用固定 epsilon 判距离 | 绝对容差在不同量级失效 | 相对容差或先归一化/平移 |
| 认为 FLOATING 最精确所以最安全 | FLOATING 只是“不舍入”,不保证稳健 | 关键链路用 OverlayNGRobust 或固定精度 |
| 在循环里对每个要素 reduce | O(n) 次 snap-rounding | 批量统一网格,或入库前一次完成 |
| 用 WKT 做中间交换 | 文本精度、性能双输 | 用 WKB 或对象传递 |
| 精度模型设得越细越好 | 超出 double 能力反而不稳健 | PrecisionUtil.safeScale 封顶 |
| 失败就 buffer(0) | 改变几何且未必有效 | 校验 → 修复 → 重算 |
| 只看结果非空不看有效性 | 无效结果继续传播 | 输出层校验 |
18.11.3 一个可复用的稳健叠加工具
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.geom.TopologyException;
import org.locationtech.jts.operation.overlayng.OverlayNG;
import org.locationtech.jts.operation.overlayng.OverlayNGRobust;
import org.locationtech.jts.operation.overlayng.PrecisionUtil;
import org.locationtech.jts.operation.valid.IsValidOp;
public final class RobustOps {
private RobustOps() {}
public enum Mode { FLOAT, FIXED, ROBUST }
public static Geometry overlay(Geometry g0, Geometry g1, int opCode, Mode mode) {
if (!IsValidOp.isValid(g0) || !IsValidOp.isValid(g1)) {
throw new IllegalArgumentException("输入几何无效,请先修复");
}
switch (mode) {
case FIXED:
PrecisionModel pm = new PrecisionModel(PrecisionUtil.safeScale(g0, g1));
return OverlayNG.overlay(g0, g1, opCode, pm);
case ROBUST:
return OverlayNGRobust.overlay(g0, g1, opCode);
case FLOAT:
default:
return OverlayNG.overlay(g0, g1, opCode);
}
}
public static boolean safeIsValid(Geometry g) {
try {
return g != null && !g.isEmpty() && IsValidOp.isValid(g);
} catch (RuntimeException ex) {
return false;
}
}
}
注意 safeIsValid 对“校验本身抛异常”做了兜底:极端损坏的几何可能让校验器也出问题,此时按“不合法”处理比让线程崩掉更合适。
18.12 常见问题与避坑
18.12.1 高频问题
问:为什么两个明明相交的多边形,intersects() 返回 false?
先查坐标是否 NaN/Inf(NaN 参与比较恒为 false),再查是否一个几何是空;最后确认两个几何的 SRID/单位是否一致(JTS 不检查 SRID,但单位不一致会让“相交”在数值上不成立)。
问:为什么 union 之后面积变大了? 可能发生了精度回退(吸附把细缝抹平后面积并进来),也可能是输入本身重叠。对比回退前后的面积,并检查输入覆盖率。
问:GeometryPrecisionReducer 之后几何的 PM 还是 FLOATING?
这是默认行为,需要 setChangePrecisionModel(true)。
问:new PrecisionModel(-0.001) 里为什么是负数?
负 scale 表示直接指定网格大小 0.001,是 1.19 起推荐的写法,比 1/scale 更稳健。
问:为什么 IsValidOp 说几何有效,但 overlay 还是抛 TopologyException? 有效性只保证单要素符合 SFS,不保证数值上适合 noding。近重合边、坐标量级、重复线段都可能导致 noding 失败。
问:能不能用 Geometry.buffer(0) 统一修复?
能修一部分自相交,但会改变几何、可能吞掉小部件、对近重合边无效,且不是所有版本行为一致。优先 GeometryFixer。
问:点是否落在线上的判断怎么写才稳? 用 PointLocator / PointLocation 或 RobustLineIntersector.computeIntersection(p, p1, p2),不要手写叉积加 epsilon。
18.12.2 避坑速查表
| 现象 | 可能原因 | 快速验证 |
|---|---|---|
| 面积出现极小负数 | 构环方向错误/退化 | 检查 isValid |
| 结果多出细碎面 | 精度回退吸附 | 对比固定精度结果 |
| 校验通过但入库报错 | 几何合法但类型/SRID 不符 | 查 SRID 与字段类型 |
| 同一算法两次结果不同 | 多线程共享可变几何/未加锁 | 查线程安全(第 20 章) |
| 极慢且 CPU 打满 | 无效输入触发大量 noding | 先修复输入 |
| 偶发 TopologyException | 近重合边/大坐标 | 固定精度重试 |
18.13 本章小结
- double 只有约 15~16 位十进制有效数字,精度是相对的;坐标量级越大,可用小数位越少。
- 灾难性抵消会让方向判断出错,而方向错误会引发 noding 失败与叠加结果错误。
- PrecisionModel 有三种类型:FLOATING(默认、不舍入)、FLOATING_SINGLE、FIXED;FIXED 用 scale 定义网格,负 scale 可直接指定网格大小(1.19+)。
- makePrecise 使用非对称算术舍入(
Math.round),负坐标舍入方向与正坐标相反,NaN/Inf 原样返回。 - GeometryPrecisionReducer 有拓扑约简(snap-rounding,保证有效,移除重复点与塌缩部件)与逐点约简(可能无效,保留重复点与塌缩)两种模式,默认不改变几何的 PM 元数据。
- PrecisionUtil.safeScale 以 14 位有效数字为上限推算安全比例尺;inherentScale/robustScale 很慢,生产慎用。
- OverlayNG 按精度模型选择 noder:FIXED 用 SnapRoundingNoder,FLOATING 用 MCIndexNoder(可能抛 TopologyException);OverlayNGRobust 提供“浮点 → 吸附(5 轮)→ snap-rounding”的回退链。
- JTS 1.21 中
Geometry.union()等高层方法默认仍用旧引擎,可用-Djts.overlay=ng切换;不要在运行中动态切换。 - 精确算术(DD、CGAlgorithmsDD、RobustLineIntersector)保证谓词正确,但交点坐标仍会舍入,全局拓扑仍需 noding 策略兜底。
- CommonBitsOp / EnhancedPrecisionOp 是历史兜底方案,新代码优先用 OverlayNGRobust。
- NaN/Inf 是合法可解析的(1.21 WKT 支持 Inf),但不是合法坐标;入口必须显式校验。
- 容差要分“网格 / 吸附 / 业务阈值”三类,带单位意识,保持
网格 << 吸附 << 业务阈值 << 最小要素的层级关系。
18.14 相关资源
- 系列目录:JTS 教程目录
- 有效性质检:第 17 章(精度问题的很多表象是“有效性/覆盖率不通过”)
- 叠加分析:第 09 章(OverlayNG 架构与操作语义)
- 线网处理与 Noding:第 14 章(noding 原理与 noder 家族)
- 坐标系与工程陷阱:第 19 章(单位与量级问题的另一半)
- 性能优化:第 20 章(精度策略与性能的权衡)
- 综合实战:第 24 章(鲁棒性清单在完整项目中的落地)
- JTS 官方 Javadoc:https://locationtech.github.io/jts/javadoc/
- 源码路径:
org/locationtech/jts/geom/PrecisionModel.java、org/locationtech/jts/precision、org/locationtech/jts/math/DD.java、org/locationtech/jts/operation/overlayng