第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
    }
}

这个行为有两层影响:

  1. 确定性:同样的输入永远得到同样的网格坐标,适合做落库前的规范化;
  2. 不对称:坐标为负时,舍入方向与正坐标相反,可能让原本对齐的两条边在 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 选择网格的三条经验

  1. 以业务精度为准,不要以 double 上限为准:地籍 1:500 数据用 0.001 米网格足矣,没必要 1e-9;
  2. 网格要比最小要素尺寸小一个数量级:1 米网格配 3 米宽的地块会把地块抹掉;
  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 三个错误做法

  1. catch 后返回空几何:调用方以为“没有交集”,实际是计算失败,静默产生业务错误;
  2. 无上限重试:同样的输入重试一万次也不会变好,只会拖垮线程池;
  3. 用 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 精确算术解决不了什么

精确谓词只保证“每一步判断正确”,但叠加分析还有两个不精确环节:

  1. 交点坐标仍要舍入成 double:精确求出的交点写回坐标数组时被舍入,可能落到某条线段的“错误一侧”,后续判断就会矛盾;
  2. 拓扑重建依赖全局一致性:构环、标注、合并是全局过程,局部谓词正确不代表全局拓扑一致。

这正是为什么 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 / 精度约简引入的偏移;
  • 面积计算对边界扰动的放大。

因此设计容差时要回答三个问题:

  1. 数据最终要满足什么精度规范(例如地籍界址点 ±5 厘米)?
  2. 哪些环节会消耗误差预算?各消耗多少?
  3. 出问题时如何判定是哪一环节的责任?
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 本章小结

  1. double 只有约 15~16 位十进制有效数字,精度是相对的;坐标量级越大,可用小数位越少。
  2. 灾难性抵消会让方向判断出错,而方向错误会引发 noding 失败与叠加结果错误。
  3. PrecisionModel 有三种类型:FLOATING(默认、不舍入)、FLOATING_SINGLE、FIXED;FIXED 用 scale 定义网格,负 scale 可直接指定网格大小(1.19+)。
  4. makePrecise 使用非对称算术舍入(Math.round),负坐标舍入方向与正坐标相反,NaN/Inf 原样返回。
  5. GeometryPrecisionReducer 有拓扑约简(snap-rounding,保证有效,移除重复点与塌缩部件)与逐点约简(可能无效,保留重复点与塌缩)两种模式,默认不改变几何的 PM 元数据。
  6. PrecisionUtil.safeScale 以 14 位有效数字为上限推算安全比例尺;inherentScale/robustScale 很慢,生产慎用。
  7. OverlayNG 按精度模型选择 noder:FIXED 用 SnapRoundingNoder,FLOATING 用 MCIndexNoder(可能抛 TopologyException);OverlayNGRobust 提供“浮点 → 吸附(5 轮)→ snap-rounding”的回退链。
  8. JTS 1.21 中 Geometry.union() 等高层方法默认仍用旧引擎,可用 -Djts.overlay=ng 切换;不要在运行中动态切换。
  9. 精确算术(DD、CGAlgorithmsDD、RobustLineIntersector)保证谓词正确,但交点坐标仍会舍入,全局拓扑仍需 noding 策略兜底。
  10. CommonBitsOp / EnhancedPrecisionOp 是历史兜底方案,新代码优先用 OverlayNGRobust。
  11. NaN/Inf 是合法可解析的(1.21 WKT 支持 Inf),但不是合法坐标;入口必须显式校验。
  12. 容差要分“网格 / 吸附 / 业务阈值”三类,带单位意识,保持 网格 << 吸附 << 业务阈值 << 最小要素 的层级关系。

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