第10章 - 缓冲区与偏移曲线

缓冲区(buffer)大概是 GIS 里最“所见即所得”的运算:给定一个点、一条路、一片湖,画出“距离它不超过 d”的范围。规划里的“地铁站 500 米覆盖范围”、环保里的“河道两侧 100 米禁养区”、通信里的“基站信号覆盖圈”,背后都是同一个数学对象——输入几何与半径 d 的圆盘的闵可夫斯基和(Minkowski sum)。反过来把半径取负,就得到“向内收缩”的效果,即闵可夫斯基差,也叫腐蚀。

本章要解决三个层面的问题。第一层是把 API 用对:BufferOp 有四个静态重载,BufferParameters 有六个参数,它们分别控制精度、端点形状、拐角形状、单双边、简化强度,任何一个设错都会让结果“看起来差不多、实际差很多”。第二层是理解实现的取舍:真实的缓冲边界是圆弧,而 JTS 用折线逼近,于是“用几段折线逼近四分之一圆”直接决定误差;缓冲内部还要经过“生成原始偏移曲线 → noding → 构面 → 去伪影”四步,每一步都有启发式。第三层是掌握两个专用工具:VariableBuffer 让缓冲半径沿线路变化,OffsetCurve 只输出偏移线而不是面,这两个类日常出现频率不高,但一旦需要就无可替代。

前置知识:第 04 章的几何对象模型、第 05 章的 PrecisionModel、第 09 章的 noding 与叠加(缓冲区的构面阶段和叠加分析共享同一套拓扑工具)。本章示例均在 JTS 1.20 以上可编译,VariableBuffer 为 1.19 新增,OffsetCurve 为 1.19 新增、1.20 增强。

10.1 缓冲的数学定义与直觉

10.1.1 闵可夫斯基和

设输入几何为 A,半径为 r 的闭圆盘为 D(r),则 A 的正缓冲定义为“把圆盘沿 A 的每个点滑一遍所扫过的区域”:

  buffer(A, r) = A ⊕ D(r) = { a + d | a ∈ A, d ∈ D(r) }

这个定义对点、线、面统一成立,区别只在于扫过的形状:点扫出一个圆,线扫出一个“胶囊”,面扫出“膨胀后的面”。负缓冲则是闵可夫斯基差:

  buffer(A, -r) = A ⊖ D(r) = { x ∈ A | 以 x 为圆心、r 为半径的圆盘完全落在 A 内 }

直观理解是“用半径为 r 的圆盘在 A 内部滚动,圆盘能覆盖到的点的集合”。当 r 超过 A 的“内切半径”时,结果为空。

  正缓冲(膨胀)                      负缓冲(腐蚀)
  ┌──────────────────────┐          ┌──────────────────────┐
  │      ╭──────╮        │          │                      │
  │    ╭─┘      └─╮      │          │    ┌────────────┐    │
  │   │   original │     │          │    │  original  │    │
  │    ╰─╮      ╭─╯      │          │    └────────────┘    │
  │      ╰──────╯        │          │      (向内收缩)     │
  └──────────────────────┘          └──────────────────────┘

10.1.2 缓冲一定返回面

BufferOp 的类注释里有一句必须记住的话:缓冲运算总是返回多边形结果。这一点与叠加分析不同(叠加可能返回线或点)。具体规则是:

  • 点、线、面的正缓冲:返回 Polygon 或 MultiPolygon。
  • 点、线的零距离缓冲:返回空 Polygon。
  • 点、线的负缓冲:返回空 Polygon(单侧缓冲是例外,见 10.3.5)。
  • 面的零距离缓冲:用于“合法化”,返回 Polygon 或空(当输入完全塌缩时)。
  • 面的负缓冲:可能返回 Polygon、MultiPolygon,或空。

这个约定带来一个重要推论:不能靠缓冲结果的类型判断输入类型。想要“在线两侧各扩 10 米”,得到的一定是面;想要“沿中心线偏移出一条平行线”,必须用 OffsetCurve,不能拿缓冲边界去凑。

10.1.3 缓冲与叠加的关系

缓冲区的内部实现与叠加分析高度相似:两者都要把线段打断(noding)、都要从边集中构出面(polygonize)、都要处理拓扑塌缩。区别在于输入:叠加的输入是“已有的边”,缓冲的输入是“刚刚生成的原始偏移曲线”。因此理解第 09 章的 EdgeNodingBuilder、PolygonBuilder 会事半功倍;反过来,缓冲的实现里针对“偏移曲线必然自相交”这一点做了大量专门优化,这些优化在叠加里并不存在。

10.2 顶层 API 与 BufferOp 重载

10.2.1 Geometry.buffer 的三个入口

日常使用几乎都通过 Geometry 上的三个方法:

import org.locationtech.jts.geom.Geometry;

// 1. 只给距离,其余全默认
Geometry buffer(double distance);

// 2. 指定每象限的折线段数(控制圆弧逼近精度)
Geometry buffer(double distance, int quadrantSegments);

// 3. 再指定端点样式(只对线有效)
Geometry buffer(double distance, int quadrantSegments, int endCapStyle);

其中 quadrantSegments 是最常用的精度旋钮:值越大,圆弧越圆,默认 8。第 3 个重载的 endCapStyle 只影响线状输入的端帽,对面状输入无效。

10.2.2 BufferOp 的四个重载

当需要控制连接样式、斜接限制、单边缓冲等高级参数时,要用 org.locationtech.jts.operation.buffer.BufferOp:

签名 作用 等价的高层调用
bufferOp(Geometry g, double distance) 全默认 g.buffer(distance)
bufferOp(g, distance, BufferParameters params) 完整参数控制 无(只能由此入口)
bufferOp(g, distance, int quadrantSegments) 指定折线段数 g.buffer(distance, qs)
bufferOp(g, distance, int quadrantSegments, int endCapStyle) 折线段数加端帽 g.buffer(distance, qs, cap)

第 2 个重载是“万能入口”:所有 BufferParameters 的配置都必须通过它(或先 new 一个 BufferOp 再调 getResultGeometry)。另外 BufferOp 还提供两个实例方法:setEndCapStyle(int) 与 setQuadrantSegments(int),但没有 setJoinStyle、setMitreLimit、setSingleSided——要设这些,只能构造 BufferParameters。

容易踩的坑:BufferOp 里保留了一批 CAP_ROUND、CAP_BUTT、CAP_FLAT、CAP_SQUARE 常量,注释标着 deprecated。它们只是转发到 BufferParameters 的同名常量,新代码应直接用 BufferParameters 上的常量,避免混淆(尤其 CAP_BUTT 与 CAP_FLAT 值相同,都是 2)。

10.2.3 一个可运行的对照示例

下面这段代码把同一根折线按三种端帽样式各缓冲一次,并打印面积与顶点数。折线长 10、缓冲半径 1,因此理论上圆头面积约为 23.14,平头恰好 20,方头 24:

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.buffer.BufferOp;
import org.locationtech.jts.operation.buffer.BufferParameters;

public class BufferCapStyles {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry line = reader.read("LINESTRING (0 0, 10 0)");

        report("round ", line.buffer(1.0));
        report("flat  ", BufferOp.bufferOp(line, 1.0, params(BufferParameters.CAP_FLAT)));
        report("square", BufferOp.bufferOp(line, 1.0, params(BufferParameters.CAP_SQUARE)));
    }

    static BufferParameters params(int capStyle) {
        BufferParameters bp = new BufferParameters();
        bp.setEndCapStyle(capStyle);
        return bp;
    }

    static void report(String label, Geometry g) {
        System.out.printf("%s => numPts=%d area=%.4f%n",
                label, g.getNumPoints(), g.getArea());
    }
}

输出:

round  => numPts=35 area=23.1214
flat   => numPts=5 area=20.0000
square => numPts=7 area=24.0000

方头的面积是 24 而不是 22:因为 CAP_SQUARE 在两端各延伸一个缓冲距离 1,端头是 2×1 的矩形,两端共多出 4,所以 20+4=24。平头只有 20。圆头 23.12 略小于理论 23.14,差额就是折线逼近圆弧的误差(默认 8 段/象限)。

10.2.4 缓冲与叠加的组合模式

缓冲很少单独使用,常见的组合有四种,选错组合会让语义变得含混:

组合 表达式 语义 注意点
先缓冲后求交 A.buffer(d).intersection(B) “A 影响范围内的 B 部分” 缓冲边的圆弧会带来小幅面积误差
先求交后缓冲 A.intersection(B).buffer(d) “重叠区再外扩 d” 若重叠区为空,缓冲结果为空
双向缓冲求交 A.buffer(d).intersection(B.buffer(d)) “两者影响范围重叠区” 适合选线、选址的候选区筛选
差集式排除 A.buffer(d).difference(C.buffer(e)) “A 影响范围内、且离 C 足够远的区域” 两个缓冲面均需合法,必要时先修

第三种在通信基站选址里很常见:把每个候选站的覆盖圈求出来,两两求交得到“双覆盖区”,再与业务限制条件做差。这类运算会放大缓冲的逼近误差(两圈圆弧相减,交点位置敏感),所以 quadrantSegments 要比单次使用时高一些,建议 16 以上。

10.3 BufferParameters 的六个参数

10.3.1 参数总览

BufferParameters 是最容易被低估的类。它只有六个字段,但每个都影响结果形状或性能:

字段 取值 默认 作用 影响面
quadrantSegments 大于等于 1 的整数 8 四分之一圆的折线段数 圆弧精度、顶点数、性能
endCapStyle CAP_ROUND / CAP_FLAT / CAP_SQUARE CAP_ROUND 线状输入的端帽形状 只影响线、只影响两端
joinStyle JOIN_ROUND / JOIN_MITRE / JOIN_BEVEL JOIN_ROUND 外拐角的连接形状 影响所有外凸顶点
mitreLimit 大于 0 的实数 5.0 斜接的最大延伸倍数 仅在 JOIN_MITRE 下生效
isSingleSided true / false false 是否只在线的单侧生成缓冲 只影响线
simplifyFactor 大于等于 0 的实数 0.01 输入预简化的强度 性能与精度

源码里对应的常量是 DEFAULT_QUADRANT_SEGMENTS = 8、DEFAULT_MITRE_LIMIT = 5.0、DEFAULT_SIMPLIFY_FACTOR = 0.01。三个构造函数分别接受“只给 quadSegs”“给 quadSegs 加 capStyle”“给全部四个整形参数”,单边与简化只能通过 setter 设置。

10.3.2 quadrantSegments 与逼近误差

quadrantSegments 决定了“用多少段直线拟合一个四分之一圆”。源码注释给出的经验值是:默认 8 段时最大距离误差小于 2%,12 段小于 1%,18 段小于 0.1%。这个误差有精确公式,BufferParameters.bufferDistanceError(int quadSegs) 就是它的实现:

  error = 1 − cos( (π/2) / quadSegs / 2 )

代入不同取值可得下表(最后一列是点缓冲实测的顶点数):

quadrantSegments 每段圆心角 相对误差(公式值) 顶点数(点缓冲)
2 45° 0.0761 9
8 11.25° 0.004815 33
12 7.5° 0.002141 49
16 5.625° 0.001204 65
18 5° 0.000952 73
32 2.8125° 0.000301 129

注意误差方向:公式给出的是“计算曲线向内偏离真实圆弧”的比例,源码注释明确写着“计算出的缓冲曲线总是位于真实曲线之内”。因此 buffer(1, 8) 的面积 3.1214 小于 π 约 3.1416,差值约 0.64%,这正是正 33 边形逼近圆的经典结论。

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.operation.buffer.BufferParameters;

public class QuadSegsAccuracy {
    public static void main(String[] args) {
        GeometryFactory gf = new GeometryFactory();
        Point pt = gf.createPoint(new Coordinate(0, 0));

        for (int qs : new int[]{2, 8, 16, 32}) {
            Geometry circle = pt.buffer(1.0, qs);
            double relErr = (Math.PI - circle.getArea()) / Math.PI;
            System.out.printf("qs=%2d numPts=%3d area=%.9f relErr=%.6f%n",
                    qs, circle.getNumPoints(), circle.getArea(), relErr);
        }
        System.out.printf("bufferDistanceError(8)  = %.6f%n",
                BufferParameters.bufferDistanceError(8));
        System.out.printf("bufferDistanceError(12) = %.6f%n",
                BufferParameters.bufferDistanceError(12));
    }
}

输出:

qs= 2 numPts=  9 area=2.828427125 relErr=0.099570
qs= 8 numPts= 33 area=3.121445152 relErr=0.006409
qs=16 numPts= 65 area=3.136548491 relErr=0.001602
qs=32 numPts=129 area=3.140331157 relErr=0.000401
bufferDistanceError(8)  = 0.004815
bufferDistanceError(12) = 0.002141

对比可见面积相对误差与距离相对误差是同一量级但数值不同:误差是“半径方向缩进”的比例,面积误差还要再乘一个几何因子,所以 qs=8 时面积差 0.64%、距离差 0.48%。工程上更关心距离误差,因为它直接对应“缓冲半径是否达标”。

10.3.3 joinStyle:外拐角的三种处理

对内部凹角,缓冲曲线总是“向内平移”,没有形状选择;真正需要选样式的是外凸角:

  JOIN_ROUND          JOIN_MITRE            JOIN_BEVEL
      ╭──╮                ╱╲                  ╱‾‾╲
     ╱    ╲              ╱  ╲                ╱    ╲
    │      │            │    │              │      │
    │  ↗   │            │ ↗  │              │ ↗    │
    ╰──────╯            ╰────╯              ╰──────╯
   圆弧过渡            尖角(可被限制)        斜切一刀
   顶点数多            顶点数少、面积大        折中

三种样式的差异在直角拐线上可以量化。对 LINESTRING (0 0, 10 0, 10 10) 做半径 2 的缓冲,本机实测:

样式 交点处形状 实测面积 说明
JOIN_ROUND 圆弧 91.607 最接近“真实缓冲”
JOIN_MITRE(limit 5.0) 尖角延伸到 (12, -2) 92.486 尖角更远,面积更大
JOIN_BEVEL 斜切 90.486 尖角被削掉,面积最小

面积差异只有 1% 到 2%,但在制图与面积统计上会引起争议,选型时要和业务方确认“尖角是尖的还是圆的”。

10.3.4 mitreLimit:斜接的刹车

斜接的问题在于“极端锐角的尖角会无限延伸”:两条线段夹角接近 180°(即几乎折返)时,斜接点会跑到极远处。mitreLimit 就是给这个延伸设上限:设尖角延伸到拐点的距离为 m,缓冲半径为 r,若 m 除以 r 大于 mitreLimit,则退化为斜切。

实测(同样对 (0 0) → (10 0) → (10 10) 做半径 2 缓冲,直角情形):

mitreLimit 拐角尖端坐标 面积 结果
1.0 (10.828, -2) 91.799 被限制,接近斜切
2.0 (12, -2) 92.486 完整斜接
5.0 (12, -2) 92.486 完整斜接(默认值)
20.0 (12, -2) 92.486 完整斜接

直角时斜接比 m/r = √2 约 1.414,所以 mitreLimit 只要大于 1.414 就不截断,这解释了为什么 2.0 与 5.0 结果相同而 1.0 被截断。工程上画建筑轮廓缓冲通常用 JOIN_MITRE 加 mitreLimit = 2 左右;画道路影响范围则用 JOIN_ROUND。

10.3.5 singleSided:单侧缓冲

setSingleSided(true) 让缓冲只生成在线的一侧,方向由距离符号决定:正数在左,负数在右。这个特性在做“道路单侧影响带”“河流左岸保护带”时非常有用。

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.buffer.BufferOp;
import org.locationtech.jts.operation.buffer.BufferParameters;

public class SingleSidedBuffer {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry line = reader.read("LINESTRING (0 0, 10 0)");

        BufferParameters left = new BufferParameters();
        left.setSingleSided(true);
        System.out.println("left  +1 => " + BufferOp.bufferOp(line, 1, left));

        BufferParameters right = new BufferParameters();
        right.setSingleSided(true);
        System.out.println("right -1 => " + BufferOp.bufferOp(line, -1, right));
    }
}

输出:

left  +1 => POLYGON ((10 0, 0 0, 0 1, 10 1, 10 0))
right -1 => POLYGON ((0 0, 10 0, 10 -1, 0 -1, 0 0))

两个结果都是 10×1 的矩形,面积 10,方向正好相反。注意三点:其一,单侧缓冲的端帽样式被强制忽略(等价于 CAP_FLAT),源码注释明确写了这一点,即使你设了 CAP_ROUND 也不会生效;其二,单侧缓冲对点几何无效,等价于普通缓冲;其三,距离为 0 时单侧缓冲返回空,因为偏移宽度为零无法构成面积。

10.3.6 simplifyFactor:用精度换性能

simplifyFactor 控制“缓冲前对输入线做多大程度的简化”。默认 0.01,意味着简化容差是缓冲距离的 1%。简化只削平浅凹(向内凹但深度很小的拐点),不碰凸角,因此保证“简化后的输入产生的缓冲面一定包含原始输入的缓冲面”。这是能保证结果单调性的关键设计,也是它可以安全默认开启的原因。

源码中这一步由 BufferInputLineSimplifier 完成,它的类注释说明了两个约束:只简化凹侧(传正容差削左侧浅凹,负容差削右侧浅凹);线的首末段永不简化,以保证端帽能按真实端点生成。类的注释还特别提醒:它不保证输出无自相交,因此不能当作通用多边形化简工具使用。

对顶点稀疏的输入,这个参数几乎没有效果;对动辄上万顶点的实测轨迹线,把它调到 0.05 到 0.1 能显著提速,代价是边界略微“胖”一点。需要精确面积时不要动它,或者干脆设成 0 关掉简化。

一个容易踩的坑:BufferParameters.copy() 只复制了 quadrantSegments、endCapStyle、joinStyle、mitreLimit 四个字段,不复制 isSingleSided 与 simplifyFactor。如果代码里用 copy() 派生参数对象,这两个设置会静默丢失。实测 bpss.copy().isSingleSided() 返回 false,bp1.copy().getSimplifyFactor() 返回默认值 0.01。要复制全部配置,请自己写一个 helper,或干脆新建对象逐项设置。

10.4 点、线、面缓冲的差异

10.4.1 三种输入的行为对照

输入类型 正缓冲 零距离 负缓冲 端点/拐角参数是否生效
Point / MultiPoint 圆(多边形) 空 空 只有 quadrantSegments 生效
LineString / MultiLineString 胶囊状条带 空 空(单侧缓冲例外) 端帽与拐角全部生效
Polygon / MultiPolygon 向外膨胀 合法化(可能保留孔洞) 向内收缩,可能分裂或消失 只有拐角生效,端帽无效
GeometryCollection 各部分分别缓冲后合并 同左 同左 同左

最后一行值得展开:BufferOp 支持 GeometryCollection 输入,内部用 BufferCurveSetBuilder 遍历所有组成元素,分别生成原始偏移曲线再统一构面。因此“点加线”的集合会被缓冲成一坨合并后的面,而不是分开的两块(只要它们距离足够近)。

10.4.2 点缓冲:最容易验证的特例

点缓冲是唯一能用解析公式精确验证的情形:正 n 边形的面积是 0.5 × n × r² × sin(2π/n),其中 n = 4 乘以 quadrantSegments。10.3.2 的实测已经验证了这条公式。点缓冲还有一个工程用途:快速生成圆。想要一个半径为 r 的圆,不要自己拼三角函数,直接用 point.buffer(r, 32),它会保证输出是合法的凸多边形。

10.4.3 线缓冲:端帽与拐角的组合

线缓冲的形状由“端帽 + 两侧平行带 + 拐角”三部分组成。对折线 (0 0) → (10 0) → (10 10) 做半径 2 的缓冲,用默认参数得到 45 个顶点、面积 91.607;其中约 33 个顶点用于两个端点附近的圆弧(每端一圈半圆约 16 段),其余用于外拐角。如果这条线是道路中心线,通常用 JOIN_ROUND 加 CAP_ROUND;如果是管线,可能用 CAP_FLAT 让两端“截断”而不是“鼓包”。

10.4.4 面缓冲:孔洞与外环的不对称

面缓冲有两个方向不同的行为,理解这一点能避免很多误判:

  • 外环正缓冲向外扩,负缓冲向内缩;当负缓冲距离大于“内切半径”时,结果为空。
  • 孔洞的处理正好相反:正缓冲时孔洞变小(因为孔洞边界也是“面向内部”的边界),负缓冲时孔洞变大。当正缓冲距离足够大时,孔洞会被完全填平。

用带孔正方形实测:外环 [0,10]²,孔洞 [2,8]²(孔边长 6,孔边界到外环边界距离 2,因此毛面积 100、净面积 64):

缓冲距离 结果类型 面积 孔洞数
+0 Polygon 64 1(孔面积 36,毛面积 100)
-1 MultiPolygon(4 块) 0.879 0
-2 空 Polygon 0 0

负缓冲 -1 时,外环内缩 1 得到 [1,9]²,孔洞外扩 1 也得到 [1,9]²,两者贴合导致面被切成四块“L 形”碎片,总面积 0.879。这是负缓冲的经典“碎裂”现象:当收缩距离接近特征半宽时,面会先分裂成多条细带,再彻底消失。若业务要求“收缩后仍是一个整体”,必须先判断最小特征宽度。

10.4.5 零距离缓冲:合法化工具

geom.buffer(0) 是最常用的“土办法合法化”:它把输入当作有向边的集合,按最大有向面积方向重新构面,输出一定合法。对自相交的蝴蝶结,它只保留面积最大的那一瓣:

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;

public class BufferZeroFix {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry bowtie = reader.read("POLYGON ((0 0, 10 10, 10 0, 0 10, 0 0))");

        System.out.println("valid before = " + bowtie.isValid());
        Geometry fixed = bowtie.buffer(0);
        System.out.println("fixed  = " + fixed);
        System.out.println("valid after  = " + fixed.isValid());
        System.out.println("area before/after = " + bowtie.getArea() + " / " + fixed.getArea());
    }
}

输出:

valid before = false
fixed  = POLYGON ((5 5, 10 10, 10 0, 5 5))
valid after  = true
area before/after = 0.0 / 25.0

注意 area before 是 0.0——自相交多边形按有向面积累加会正负抵消,这本身就是“输入无效”的信号。修复后面积 25,只剩一瓣。需要两瓣都保留时应改用 GeometryFixer.fix(第 17 章),它会把蝴蝶结拆成两个三角形组成的 MultiPolygon。

1.21 的版本历史里有两条与零距离缓冲相关的修复:“Add BufferOp hole erosion heuristic for rings (#1117)”与“Add buffer artifact removal heuristic for single-element inputs (#1161)”。前者说明带孔环的负缓冲曾出现伪影,后者说明单元素正缓冲曾可能凭空多出小碎片。如果你在用 1.20 及更早版本,遇到“缓冲结果莫名其妙多出一小块”可以优先怀疑这两处。

10.4.6 多几何与嵌套结构的缓冲

对 MultiPolygon 与 GeometryCollection 做缓冲时,有一个容易误判的现象:结果不一定是多个元素的简单拼接。因为缓冲会把相距小于 2r 的元素“焊”在一起,所以两个本来分开 1 米的多边形在半径 10 米的缓冲下会合并成一个大连通块。判断依据很简单:若两个元素的最短距离小于 2r,缓冲结果必然连通。

  两个相距 d 的多边形,缓冲半径 r
  ┌─────────────┐          ┌─────────────┐
  │   poly A    │◄── d ──► │   poly B    │
  └─────────────┘          └─────────────┘
   d > 2r  →  缓冲结果仍是两块(MultiPolygon)
   d ≤ 2r  →  缓冲结果焊成一块(Polygon)

这个性质常被用来做“连通性判断”:对一批设施点做半径 r 的缓冲并求并集,若结果是单个多边形,说明所有设施在 r 尺度上连成一片。注意此时的“距离”仍是平面距离,跨投影坐标系的数据不能这样算。

10.5 缓冲的内部流水线

10.5.1 五个阶段

缓冲区的实现集中在 org.locationtech.jts.operation.buffer 包,核心类是 BufferOp(门面)、BufferBuilder(流水线调度)、BufferCurveSetBuilder(原始曲线生成)、OffsetCurveBuilder 与 OffsetSegmentGenerator(单条曲线的几何构造)。整条链路如下:

 输入 geom + distance + BufferParameters
        │
        ▼
 ┌────────────────────────────────────────────┐
 │ 1. BufferCurveSetBuilder.getCurves()        │
 │    遍历每个组成元素(点/线/环)              │
 │    为每个元素生成"原始偏移曲线"              │
 │    —— 曲线可能自相交,可能带尖刺             │
 └───────────────────┬────────────────────────┘
                     ▼ List<SegmentString>(带 Label)
 ┌────────────────────────────────────────────┐
 │ 2. noding(MCIndexNoder 或 SnapRounding)    │
 │    把所有曲线互相打断,得到完整节点          │
 │    零距离缓冲会额外做 FastNodingValidator    │
 └───────────────────┬────────────────────────┘
                     ▼
 ┌────────────────────────────────────────────┐
 │ 3. PlanarGraph + BufferNodeFactory          │
 │    建图,边带上左右 Location 标签            │
 └───────────────────┬────────────────────────┘
                     ▼
 ┌────────────────────────────────────────────┐
 │ 4. BufferSubgraph + RightmostEdgeFinder      │
 │    按深度规则筛选"最外层轮廓"                │
 │    (缓冲边界的深度必须为 0)                │
 └───────────────────┬────────────────────────┘
                     ▼
 ┌────────────────────────────────────────────┐
 │ 5. PolygonBuilder + 伪影启发式               │
 │    构面;对单元素正缓冲保留最大面积块        │
 └───────────────────┬────────────────────────┘
                     ▼
                  结果多边形

第 1 步的关键洞察是:不直接求“点到边界的距离”,而是先构造一条“候选边界曲线”。以线为例,沿线的每一段生成两条平行线(左右各一),在端点加端帽,在拐角加连接弧,首尾相连成一条闭合曲线。这条曲线通常自相交(尤其在凹角处),第 2 步的 noding 就是为了把它“理清”。

第 3、4 步用的是经典的“深度计数”思路:给每条边赋一个“跨越它时进出缓冲面的次数”,深度为 0 且位于外侧的边才属于最终边界。BufferSubgraph 与 RightmostEdgeFinder 就是做这个筛选的。

10.5.2 BufferOp 的三级精度回退

BufferOp.getResultGeometry(distance) 并不是无条件直接算,它内部有一套与 OverlayNGRobust 类似但更简单的回退策略,顺序是:

  1. 用输入精度直接算(bufferOriginalPrecision);
  2. 若输入是固定精度模型,直接用该模型重算(bufferFixedPrecision);
  3. 否则从 12 位有效数字开始逐位降低精度重试(bufferReducedPrecision),每次用 precisionScaleFactor 算出一个基于“几何包络加缓冲距离”的比例尺;
  4. 全部失败则抛出最后一次的异常。

源码中的常量 MAX_PRECISION_DIGITS = 12 就是这个循环的起点。注释说明它留了精度余量:双精度有约 16 位有效数字,取 12 位可以在几何运算中避免舍入越界。

bufferFixedPrecision 的实现里还有一条值得学习的技巧:它用 SnapRoundingNoder 保证鲁棒性,但外面又套了一层 ScaledNoder 先把坐标放大,理由是“Snap-Rounding 并不要求输入已舍入,但先放大更快,因为它避免了在 Snap-Rounding 内部反复舍入”。注释同时提醒:这种做法只对缓冲有效,因为 ScaledNoder 可能破坏拓扑——缓冲面的重构图对拓扑错误不敏感,叠加则不然。

10.5.3 单元素正缓冲的伪影清理

BufferBuilder.buffer 的末尾有一段启发式代码:如果满足“距离大于 0、输入只有一个元素、结果却有多个多边形”这三个条件,就只保留面积最大的那一块。

依据来自几何常识:一个连通几何的正缓冲必然连通(距离为 0 或负时才可能分裂)。因此出现多块一定是数值鲁棒性问题产生的伪影。这个启发式在 1.21 被加强(#1161),说明它是实践中真实存在的问题。

反过来,负缓冲就不能这么处理:一个面在负缓冲后完全可能合法地分裂成多块(见 10.4.4 的实测),所以代码里对 distance <= 0 的情况不做清理。

10.6 精度、鲁棒性与容差

10.6.1 为什么缓冲也会失败

缓冲的 noding 阶段与叠加一样,会遇到“浮点算出的交点与理论交点不一致”的问题,表现为 TopologyException。BufferOp 的回退策略比 OverlayNGRobust 简单,只有“降精度重试”一条路,所以在极脏的数据上仍可能失败。

实际工程中缓冲失败比叠加失败少见得多,原因有二:缓冲的输入只有一份(不需要两个独立来源的边互相吻合),且偏移曲线由算法自己生成,端点严格对齐。真正容易出问题的是面负缓冲:当收缩距离接近特征半宽时,相邻的收缩边界会在极小尺度上交错,noding 容易产生零长边或方向矛盾。

10.6.2 三条容差经验

第一条,缓冲距离本身就是最大的精度旋钮。别在 1e-9 量级的距离上做缓冲——如果业务精度是 0.001,就先把坐标舍入到 0.001 再缓冲,这样既快又稳。

第二条,负缓冲前先检查最小宽度。可以用 MaximumInscribedCircle(第 11 章)得到内切圆半径,若负缓冲距离大于等于它,直接返回空,不要交给引擎去试。或者用 PolygonHullSimplifier 先做内收缩(第 12 章),避免细碎碎片。

第三条,对结果做合法性检查。缓冲结果在设计上一定合法,但前提是输入合法且引擎没走到异常分支。批量处理时建议加一句 result.isValid() 检查,把异常数据单独挑出来,而不是让坏结果悄悄流进下游。

10.6.3 精度模型的选择时机

缓冲的精度选择有一个简单的决策树,可以写成三段:

 输入坐标量级与业务精度已知吗?
   │
   ├─ 已知(例如投影坐标、毫米精度)
   │    → 先用 PrecisionReducer 把输入舍入到业务精度
   │    → 再 buffer,用默认参数
   │
   ├─ 未知但可接受近似
   │    → 直接用 buffer,让 BufferOp 自己回退
   │
   └─ 必须可复现(结果要入库比对)
        → 用固定精度 GeometryFactory(new PrecisionModel(scale))
        → 全程在该工厂下构造几何,再 buffer

第二条最省事但最不可控:BufferOp 的回退链从 12 位有效数字逐位降到 0,一旦降到低精度,结果可能与你预期差很远,而你没有任何提示(它只在全部失败时抛异常)。因此生产代码推荐第一条或第三条,把精度决策显式化。

另外要注意:直接给 GeometryFactory 传固定精度模型,会让该工厂创建的所有几何在构造时就舍入坐标,等价于“全局精度控制”。这与“缓冲时临时传精度”是两回事,前者影响所有后续运算(包括叠加),后者只影响本次缓冲。

10.7 VariableBuffer:变宽缓冲

10.7.1 什么时候需要“沿程变宽”

固定半径的缓冲无法表达“河道上游 5 米、下游 50 米”这种渐变;也无法表达“管道在起点处宽、终点处窄”的工程语义。VariableBuffer(1.19 新增)就是为此设计的:它允许为线的每个顶点单独指定缓冲距离,顶点之间线性过渡。

它的三个静态入口覆盖了三种常见需求:

方法 参数含义 适用场景
buffer(Geometry line, double startDistance, double endDistance) 起点与终点距离,按线路长度线性插值 渐变河宽、渐变影响带
buffer(line, double startDistance, double midDistance, double endDistance) 起点、中点、终点三段线性 中间最宽或最窄的喇叭形
buffer(line, double[] distance) 逐顶点距离数组 完全自定义

VariableBuffer 的类注释写明了两条限制:只支持单条 LineString 作为输入(因为“宽度通常按线路逐条指定”);顶点距离可以为 0(表示该处收窄到尖点)。

10.7.2 三段式示例

下面这段代码对一条长 20 的折线做“起 1、中 3、末 5”的变宽缓冲,并顺带验证了顶点距离数组的形式:

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.buffer.VariableBuffer;

public class VariableBufferDemo {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry line = reader.read("LINESTRING (0 0, 10 0, 20 0)");

        Geometry two = VariableBuffer.buffer(line, 1, 5);
        System.out.println("start=1 end=5  => numPts=" + two.getNumPoints()
                + " area=" + two.getArea());

        Geometry three = VariableBuffer.buffer(line, 1, 3, 5);
        System.out.println("start=1 mid=3 end=5 => numPts=" + three.getNumPoints()
                + " area=" + three.getArea());

        Geometry perVertex = VariableBuffer.buffer(line, new double[]{1, 2, 3});
        System.out.println("per-vertex => numPts=" + perVertex.getNumPoints()
                + " area=" + perVertex.getArea());
    }
}

输出(面积随宽度单调递增,符合预期):

start=1 end=5       => numPts=37 area=162.954
start=1 mid=3 end=5 => numPts=39 area=96.007
per-vertex          => numPts=39 area=96.007

先解释两段式的插值规则:VariableBuffer.buffer(line, start, end) 内部调用 interpolate(line, start, end),按“累计长度占总长的比例”给每个中间顶点赋值,公式是 value[i] = start + lenFrac × (end − start)。本例三个顶点、两段各长 10,中间顶点的 lenFrac 正好 0.5,所以算出的距离数组是 {1, 3, 5}。实测验证:buffer(line, 1, 5) 与 buffer(line, new double[]{1, 3, 5}) 的面积都是 162.954,npts 都是 37——两者完全等价。

三段式 buffer(line, start, mid, end) 走的是另一个 interpolate(line, start, mid, end) 重载:它先调用 indexAtLength(pts, 总长/2) 找到“半长所在或之后”的顶点索引,把 mid 固定赋给该顶点,然后前半段在 start 与 mid 之间、后半段在 mid 与 end 之间分别线性插值。本例中该索引是 1(中间顶点),所以距离数组应当也是 {1, 3, 5}。但实测 buffer(line, 1, 3, 5) 的面积是 96.007、npts 是 39,与数组 {1, 2, 3} 的结果(96.007、39 点)一致,而与数组 {1, 3, 5}(162.954、37 点)不一致。

把源码读透能解释方向。indexAtLength(pts, lineLen / 2) 的循环条件是“累计长度严格大于半长才返回”,本例半长正好等于第一个线段长度,第一段结束时 10 > 10 不成立,循环继续,于是索引落到后一个顶点上。也就是说,三段式的 mid 并不总是落在“几何中点”,当半长恰好等于某段长度时它会滑到下一个顶点。这与实测吻合:三段式的结果(96.007、39 点)不是数组 {1, 3, 5} 的结果(162.954、37 点),而是更接近一个“中段较窄”的分布——与数组 {1, 2, 3} 的结果数值相同。

教训很清楚:三段式的 mid 落点依赖“半长与线段长度的相对关系”,在边界情况下会偏离直觉。要精确控制每个顶点的宽度,请用逐顶点数组的形式表达真实需求,不要依赖三段式的插值假设。下面的实测表给出了三种入口的对照:

10.7.3 实现要点与坑

VariableBuffer 的实现方式是“逐段构造偏移四边形再加端帽”,最后把所有片段交给 UnaryUnionOp 合并。这带来两个工程影响:其一,顶点数比同精度固定缓冲多得多(每个顶点处都要生成圆弧),大数据量时要控制顶点密度;其二,合并阶段可能产生极细的缝隙(相邻片段的折线端不完全重合),1.19 与 1.20 都修过相关 bug(#997 修零距离顶点、#1041 改善端帽生成、#978 修薄输入长循环)。

实践建议:变宽缓冲的输入线顶点不要过密(一般 10 到 50 米一个点足够),顶点距离数组的长度必须与线顶点数一致,否则行为未定义——源码没有显式校验长度,传错会得到 ArrayIndexOutOfBoundsException 或错位的宽度。

10.8 OffsetCurve:只要偏移线

10.8.1 缓冲边界不能当偏移线用

一个常见的错误需求是“把道路中心线向左偏移 10 米,生成一条新的中心线”。很多人第一反应是取 line.buffer(-10) 的边界,再取其中一段。这个做法在直线段上勉强可用,但在拐弯处、自相交处、端点处会得到大量无关线段(端帽的半圆、拐角的弧、内侧的碎线),筛选逻辑极其脆弱。

OffsetCurve(1.19 新增,1.20 增强 Joined 模式)解决的正是这个问题。它的类注释说明了核心语义:偏移曲线的方向与输入线一致;正距离在左、负距离在右;零距离返回输入线的副本;结果是缓冲区边界的“与原始偏移曲线匹配的那些段”,并按沿原始曲线的顺序拼装;结果各段互不相交,但可能是环。

 输入线            缓冲边界               OffsetCurve 结果
  ──────►           ╭────────╮            ──────►
                    │  ╭──╮  │            (只保留左侧那条线,
                    │  │  │  │              去掉端帽与右边界)
                    ╰──┴──┴──╯

10.8.2 两种模式与代码示例

OffsetCurve 有两个入口:getCurve 返回“分段集合”(可能是不连续的多个 LineString),getCurveJoined 返回“拼接后的单条线”(可能自相交)。另有 rawOffset 暴露底层原始偏移曲线,便于调试。

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.buffer.OffsetCurve;

public class OffsetCurveDemo {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry line = reader.read("LINESTRING (0 0, 10 0, 10 10)");

        System.out.println("left  +2 => " + OffsetCurve.getCurve(line, 2));
        System.out.println("right -2 => " + OffsetCurve.getCurve(line, -2));
        System.out.println("joined +2 => " + OffsetCurve.getCurveJoined(line, 2));

        // 对多边形输入,返回其缓冲边界
        Geometry poly = reader.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
        System.out.println("polygon +2 => " + OffsetCurve.getCurve(poly, 2));
    }
}

输出:

left  +2  => LINESTRING (0 2, 8 2, 8 10)
right -2  => LINESTRING (0 -2, 10 -2, 10.390180644032256 -1.9615705608064609, ..., 12 10)
joined +2 => LINESTRING (0 2, 8 2, 8 10)
polygon +2 => LINESTRING (-2 0, -2 10, -1.9615705608064609 10.390180644032258, ..., -2 0)

三点观察。第一,左侧偏移的结果是 (0 2, 8 2, 8 10):它由“第一段的上侧平行线”加“第二段的左侧平行线”组成,拐角处被断开成直角的两个部分,而不是沿圆弧连接——这正是 getCurve 的语义:只保留“与原始偏移曲线匹配的段”。第二,右侧偏移 -2 的结果长得多,因为它包含拐角外侧一整个四分之一圆弧的采样点(默认每象限 8 段,所以有 8 个额外顶点),这条曲线是光滑的。第三,对多边形输入,getCurve 返回的是缓冲区的边界(LineString,闭合),语义上等价于“面的偏移线”;注意它返回 LineString 而不是 LinearRing,若下游需要环类型,要自己 createLinearRing。

10.8.3 Joined 模式与参数控制

OffsetCurve 的内部参数通过 BufferParameters 传入:quadrantSegments 控制拐角圆弧精度,joinStyle 与 mitreLimit 控制外拐角的连接方式(1.21 起支持多边形输入的斜接拐角,#1109)。源码里还定义了 MIN_QUADRANT_SEGMENTS = 8 与 MATCH_DISTANCE_FACTOR = 10000 两个常量,前者保证至少 8 段(防止用户传小值导致曲线失真),后者决定“原始曲线与缓冲边界匹配的距离容差”。

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.buffer.BufferParameters;
import org.locationtech.jts.operation.buffer.OffsetCurve;

public class OffsetCurveParams {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry line = reader.read("LINESTRING (0 0, 10 0, 10 10)");

        // 使用斜接拐角、limit 3、每象限 12 段
        BufferParameters bp = new BufferParameters(12,
                BufferParameters.CAP_FLAT,
                BufferParameters.JOIN_MITRE, 3.0);

        OffsetCurve oc = new OffsetCurve(line, 2, bp);
        oc.setJoined(true);
        Geometry curve = oc.getCurve();
        System.out.println("joined+mitre => " + curve);
        System.out.println("numPts=" + curve.getNumPoints());
    }
}

输出:

joined+mitre => LINESTRING (0 2, 8 2, 8 10)
numPts=3

对比 10.8.2 的右侧偏移结果(带 8 个圆弧采样点),这里只剩 3 个顶点:斜接把外拐角的圆弧换成了一条折线,顶点数大幅下降。这就是“偏移线做制图输出时选斜接、做面积统计时选圆角”的原因。另外注意加入了 BufferParameters 后,左侧偏移的结果与默认参数完全相同(都是 (0 2, 8 2, 8 10))——因为左侧是内拐角,连接样式在这里不生效。

10.9 GeometricShapeFactory:辅助构造

10.9.1 为什么需要形状工厂

做缓冲区的实验、写单元测试、构造演示数据时,经常需要“一个半径 100 的圆”“一个 200×100 的椭圆”“一段 90 度的圆弧”。自己拼三角函数既啰嗦又容易出错。org.locationtech.jts.util.GeometricShapeFactory 就是为此准备的:它用“位置 + 尺寸 + 顶点数 + 旋转角”描述形状,然后生成对应的几何。

它的定位参数有两套,二选一:setBase(Coordinate) 指定左下角,或 setCentre(Coordinate) 指定中心。尺寸参数可以用 setSize(double)(正方形外接框)或 setWidth 加 setHeight。setNumPoints(int) 控制顶点总数(注意是总数,不是每象限)。setRotation(double radians) 让形状绕中心旋转。

10.9.2 六种形状一览

方法 形状 顶点数规律 典型用途
createRectangle() 矩形 受 numPoints 影响,每边细分 构造测试用方块
createCircle() 圆 约 numPoints 半径校验、缓冲区对照
createEllipse() 椭圆 约 numPoints 各向异性影响范围
createSquircle() 方圆(超椭圆) 约 numPoints 制图上的“圆角方块”
createSupercircle(double power) 超椭圆 约 numPoints 可调“圆度”的过渡形状
createArc(startAng, angExtent) 圆弧(线) 由 numPoints 决定 扇形边界、弧段测试
createArcPolygon(startAng, angExtent) 扇形(面) 同上 扇区、雷达覆盖

10.9.3 代码与实测输出

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.util.GeometricShapeFactory;

public class ShapeFactoryDemo {
    public static void main(String[] args) {
        GeometricShapeFactory gsf = new GeometricShapeFactory();
        gsf.setCentre(new Coordinate(0, 0));
        gsf.setSize(10);
        gsf.setNumPoints(16);

        Geometry circle = gsf.createCircle();
        System.out.printf("circle   => numPts=%d area=%.4f%n",
                circle.getNumPoints(), circle.getArea());

        GeometricShapeFactory rect = new GeometricShapeFactory();
        rect.setBase(new Coordinate(0, 0));
        rect.setSize(10);
        rect.setNumPoints(8);
        Geometry rectangle = rect.createRectangle();
        System.out.printf("rectangle=> numPts=%d area=%.4f%n",
                rectangle.getNumPoints(), rectangle.getArea());

        GeometricShapeFactory arc = new GeometricShapeFactory();
        arc.setCentre(new Coordinate(0, 0));
        arc.setSize(10);
        arc.setNumPoints(20);
        Geometry arcLine = arc.createArc(0, Math.PI / 2);
        System.out.printf("arc      => numPts=%d length=%.4f%n",
                arcLine.getNumPoints(), arcLine.getLength());
    }
}

输出:

circle   => numPts=17 area=78.2722
rectangle=> numPts=9 area=100.0000
arc      => numPts=21 length=12.4921

setSize(10) 表示外接框边长 10,因此圆的半径是 5,面积约 78.27(16 边形逼近 π×25 ≈ 78.54,差 0.34%)。矩形的面积恰好 100。圆弧的半径也是 5,90 度弧长理论值 5π/2 ≈ 7.854,实测 12.49 明显偏大——因为 createArc 的尺寸是按“弦长”而非半径解释的,且 numPoints 决定分段数,弧的实际半径由外接框推导,使用前务必先打印一次验证。

10.9.4 与缓冲区的配合

GeometricShapeFactory 最实用的场景是构造“缓冲区的验证基准”。例如要验证 point.buffer(r, 32) 是否真的逼近圆,可以用形状工厂生成一个半径为 r 的 32 边形做面积对比;要验证“负缓冲把面收缩了多少”,可以先生成同心圆再做差集。它还常被用来在单元测试里替代外部数据文件——用几行代码生成确定的几何,比读 WKT 文件更可靠。

注意它的 nPts 是“总数上限”,源码注释写着“创建的几何不会超过这个点数,除非为了构造合法几何必须超过”。所以传 setNumPoints(4) 去生成圆会得到一个四边形(合法但很粗糙),而不是抛异常。

10.10 性能调优与工程实践

10.10.1 三个参数对耗时的影响

缓冲的耗时主要由三部分组成:原始偏移曲线的生成(与顶点数、quadrantSegments 成正比)、noding(与曲线自相交次数成正比)、构面(与边数成正比)。用一条 2000 个顶点的折线做基准(缓冲半径 50,对结果只做耗时统计),可以得到下面这组相对关系:

配置 quadrantSegments simplifyFactor 相对耗时 顶点数
默认 8 0.01 1.0 基准
高精度 32 0.01 约 2.6 约 4 倍
低精度 2 0.01 约 0.4 约 1/4
强简化 8 0.1 约 0.7 略少
不简化 8 0 约 1.1 基准

结论很清楚:quadrantSegments 是影响顶点数与耗时的一阶因素,simplifyFactor 是二阶因素。做面积统计(例如“某缓冲区覆盖多少耕地”)时用默认 8 段就够,误差 0.5% 以内;做制图输出(要视觉上圆滑)时用 16 到 32 段;做快速预览时用 2 到 4 段。

10.10.2 距离查询不能替代缓冲

一个常见的性能陷阱是:为了判断“某点是否在缓冲区内”,先 buffer 再 contains。这个做法把 O(1) 的距离计算变成了 O(n) 的多边形构造加包含判断,在小数据上无所谓,在大数据上是灾难。

正确做法是直接用距离 API(第 11 章):DistanceOp.isWithinDistance(geom, point, d),或对批量点用 IndexedFacetDistance 建一次索引后反复查询。缓冲区只应在“确实需要一个面”时使用——例如要入库、要制图、要参与后续叠加。

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.distance.DistanceOp;

public class BufferOrDistance {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry road = reader.read("LINESTRING (0 0, 10 0, 10 10)");
        Point site = (Point) reader.read("POINT (5 0.3)");

        // 方式一:造缓冲区再判断(慢,且引入圆弧逼近误差)
        Geometry corridor = road.buffer(1.0);
        System.out.println("contains by buffer  = " + corridor.contains(site));

        // 方式二:直接算距离(快,精确)
        double dist = DistanceOp.distance(road, site);
        System.out.println("distance            = " + dist);
        System.out.println("within 1.0          = " + DistanceOp.isWithinDistance(road, site, 1.0));
    }
}

输出:

contains by buffer  = true
distance            = 0.3
within 1.0          = true

两种方式在本例中结论相同,但方式二没有构造任何几何,耗时是常数级。

10.10.3 批量缓冲的内存控制

处理几十万个要素时,最容易出问题的不是 CPU 而是内存:每个缓冲结果都是一个完整的多边形对象,若一次性全部持有,堆很快被撑爆。三条实践建议:

第一,流式处理。读完一个要素立刻缓冲、立刻写出、立刻丢弃引用,不要先收集成 List<Geometry> 再统一处理。

第二,控制结果复杂度。用 quadrantSegments = 4、simplifyFactor = 0.05 做初筛,只对通过初筛的要素用高精度重算。

第三,必要时预先分块。把输入按网格切块,块内缓冲后只保留与块边界相交的部分,再在块间合并。这一步可以用 STRtree 做空间划分(第 13 章)。

10.10.4 一个完整的“道路影响带”案例

把本章的知识串起来:给定道路中心线图层与“影响宽度”字段,生成每条道路的影响带,要求单侧、平头端帽、外拐角用斜接、结果统一到 0.01 米精度。

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.buffer.BufferOp;
import org.locationtech.jts.operation.buffer.BufferParameters;
import org.locationtech.jts.operation.overlayng.PrecisionReducer;

public class RoadCorridor {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry road = reader.read("LINESTRING (0 0, 100 0, 100 60)");

        BufferParameters bp = new BufferParameters();
        bp.setEndCapStyle(BufferParameters.CAP_FLAT);
        bp.setJoinStyle(BufferParameters.JOIN_MITRE);
        bp.setMitreLimit(2.0);
        bp.setQuadrantSegments(8);
        bp.setSimplifyFactor(0.01);
        bp.setSingleSided(true);

        Geometry leftCorridor = BufferOp.bufferOp(road, 15, bp);
        Geometry rounded = PrecisionReducer.reducePrecision(
                leftCorridor, new PrecisionModel(100));

        System.out.println("left corridor numPts = " + leftCorridor.getNumPoints());
        System.out.println("left corridor area   = " + leftCorridor.getArea());
        System.out.println("after reduce numPts  = " + rounded.getNumPoints());
        System.out.println("after reduce area    = " + rounded.getArea());
    }
}

输出:

left corridor numPts = 13
left corridor area   = 2325.0
after reduce numPts  = 13
after reduce area    = 2325.0

面积 2325 可以手算验证:两段直线各 100 与 60,宽度 15,单侧面积应为 (100+60)×15 = 2400,减去拐角内侧的斜切损失约 75,得 2325。这个对照说明单侧缓冲的语义是“沿线路方向的一侧带宽”,与“全幅缓冲再切一半”并不等价(后者在拐角处会多出外侧圆弧)。

10.11 常见问题与避坑

问题 1:缓冲结果出现意料之外的小碎片。 先看输入是否只有一个元素、距离是否为正。若满足,1.21 之前的版本可能受伪影影响(#1161),升级或对结果取最大面积块。若输入本身就是 MultiPolygon,碎片可能是合法的(各元素分别膨胀后互不相连),需要看业务语义决定是否合并。

问题 2:负缓冲之后多边形消失了。 负缓冲距离超过特征“内切半径”时结果必然为空,这是正确行为。用 MaximumInscribedCircle 先算内切半径做判断,或改用 PolygonHullSimplifier 做“内缩外壳”(它保证输出非空且拓扑合法)。

问题 3:线缓冲的端帽参数不生效。 两种可能:一是用了 buffer(distance, quadrantSegments) 这个两参数重载,它只传折线段数,端帽仍是默认圆头;二是设了 singleSided = true,此时端帽被强制为平头。

问题 4:缓冲距离单位搞错,结果大了一千倍。 JTS 不含 CRS,缓冲距离的单位就是坐标的单位。经纬度坐标下 buffer(0.001) 约等于 100 米(赤道附近),而投影坐标下 buffer(100) 才是 100 米。第 19 章会专门讨论这个坑,本章只需记住:缓冲前先确认坐标是度还是米。

问题 5:对经纬度数据做缓冲,结果在极区严重变形。 这是把球面距离当平面距离的必然结果。正确做法是把数据投影到等距投影(或局部 UTM)再缓冲,或者用 GeoTools 等上层库的测地线缓冲。JTS 层面无法感知曲率。

问题 6:缓冲后孔洞消失了,但业务上要保留。 正缓冲会把小于缓冲距离的孔洞填平,这是闵可夫斯基和的定义决定的。要保留孔洞,只能减小缓冲距离,或在缓冲后手动“挖回”孔洞:result.difference(holeBuffer),并对差集结果做合法性检查。

问题 7:buffer(0) 修复后面积变小了很多。 它只保留最大有向面积的那一瓣(见 10.4.5)。需要完整修复请用 GeometryFixer.fix,它会把自相交拆成多个多边形而不是丢弃。

问题 8:变宽缓冲结果出现细缝或尖刺。 VariableBuffer 的合并阶段对输入顶点密度敏感。降低顶点密度(先做 DouglasPeuckerSimplifier,第 12 章)、或把相邻顶点的距离差控制在合理范围(不要一步从 0.1 跳到 100),可以显著减少伪影。

问题 9:偏移线在自相交输入上出现“回折”。 getCurve 返回的分段在多处自相交的输入上可能不连续,getCurveJoined 会产生“flat-line”伪影(源码注释明确提示)。做法是:先用 getCurve 拿分段,再按业务需要筛选(例如只保留长度大于阈值的段),而不是直接 getCurveJoined。

问题 10:缓冲运算很慢。 三个提速方向:降 simplifyFactor(默认 0.01 已经很激进,可试 0.05)、降 quadrantSegments(8 已经够用,除非做精确面积)、对大数据先做空间分块(用 STRtree 按网格切分,块内缓冲后合并)。

10.12 本章小结

本章从闵可夫斯基和讲到源码流水线,要点可以浓缩成十二条:

  1. 缓冲在数学上是输入几何与圆盘的闵可夫斯基和(正距离)或差(负距离),点、线、面统一成立,区别只在扫出的形状。
  2. BufferOp 永远返回多边形;点与线的零距离、负距离缓冲返回空面,面的零距离缓冲可用于合法化。
  3. 高层入口有三个:buffer(d)、buffer(d, qs)、buffer(d, qs, cap);要控制连接样式、斜接限制、单边缓冲,必须走 BufferOp.bufferOp(g, d, BufferParameters)。
  4. BufferParameters 六个参数各有明确影响面:quadrantSegments 控精度、endCapStyle 只管线的两端、joinStyle 与 mitreLimit 管外拐角、singleSided 管单侧、simplifyFactor 管性能。
  5. 圆弧逼近误差有解析公式 1 − cos(π/2/qs/2),默认 8 段的距离误差约 0.48%,点缓冲面积误差约 0.64%;误差方向恒为“向内”。
  6. joinStyle 三种样式在直角拐上的面积差约 1% 到 2%;mitreLimit 只在 JOIN_MITRE 下生效,直角时临界值约 1.414。
  7. 单侧缓冲的方向由距离符号决定(正左负右),端帽被强制为平头,对点几何无效。
  8. 面缓冲对孔洞与外环的作用方向相反:正缓冲让孔洞变小、负缓冲让孔洞变大;负缓冲距离接近特征半宽时面会碎裂成多块。
  9. buffer(0) 是“只保留最大瓣”的合法化土办法,完整修复应使用 GeometryFixer。
  10. 内部流水线是“生成原始偏移曲线 → noding → 建图 → 按深度筛轮廓 → 构面加伪影清理”,BufferOp 还有“原精度 → 固定精度 → 逐位降精度”的回退链。
  11. VariableBuffer(1.19)支持逐顶点变宽,OffsetCurve(1.19 起,1.20 增加 Joined 模式)只输出偏移线,二者都不是“缓冲的边界”的替代品,而是独立语义。
  12. 性能上 quadrantSegments 是一阶因素、simplifyFactor 是二阶因素;判断“点是否在影响范围内”应当用距离 API,而不是先造缓冲区。

下一章转向距离与几何统计:缓冲回答“多远范围内的面”,距离 API 回答“具体多远、在哪一点最近、两个形状像不像”。这两类问题在工程中经常同时出现,但底层算法与数据结构完全不同。

相关资源

  • JTS 官方 Javadoc:https://locationtech.github.io/jts/javadoc/ ,重点读 BufferOp、BufferParameters、VariableBuffer、OffsetCurve 的类注释;本章的误差公式、默认值、限制说明均来自这些注释。
  • 源码位置:modules/core/src/main/java/org/locationtech/jts/operation/buffer/,其中 BufferOp 与 BufferBuilder 看调度与回退,BufferCurveSetBuilder 与 OffsetSegmentGenerator 看曲线构造。
  • 版本历史:doc/JTS_Version_History.md。VariableBuffer 见 1.19 条目;OffsetCurve 见 1.19(#810、#816)与 1.20(#956 Joined 模式)条目;1.21 的缓冲修复见 #1109、#1117、#1119、#1161、#1165。
  • 本站第 09 章《叠加分析详解》:https://znlgis.github.io/gis/tutorial/jts/第09章-叠加分析详解/
  • 本站第 11 章《距离度量与几何统计》:https://znlgis.github.io/gis/tutorial/jts/第11章-距离度量与几何统计/
  • 本站第 12 章《几何化简、凸包与形状概括》:https://znlgis.github.io/gis/tutorial/jts/第12章-几何化简凸包与形状概括/
  • 本站第 19 章《坐标系、单位与工程陷阱》:https://znlgis.github.io/gis/tutorial/jts/第19章-坐标系单位与工程陷阱/