第16章 - 线性参考系统

线性参考(Linear Referencing)是一套“用里程描述位置”的方法。公路管理部门说“G15 沈海高速 K123+450 处发生事故”,管线部门说“西气东输二线 358.2 公里处发现腐蚀点”,河道部门说“长江干流 1024 公里处水质超标”——这些描述里没有坐标,只有“在哪条线上、离起点多远”。把这种“线路 + 里程”的描述与地图坐标互相转换,就是线性参考系统(LRS)要解决的问题。

JTS 的 org.locationtech.jts.linearref 包提供了线性参考的核心能力:把点投影到线上算出里程、按里程从线上取点、截取一段线、把子线段的起终点换算成里程区间、沿线等距插点。它比 GIS 平台里的 LRS 简单得多——没有“路线表 + 度量表”的数据库模型,没有 M 值,也不处理路线改线后的历史里程迁移——但它是所有这些上层功能在几何层的引擎。

本章从 JTS 的坐标模型讲起,逐个剖析 LinearLocation、LengthIndexedLine、LocationIndexedLine、LengthLocationMap、LinearIterator、LinearGeometryBuilder 与 ExtractLineByLocation,然后用公路桩号、管线截取、河道分段统计三个案例把它们串起来。读完后你应该能独立实现“里程—坐标”双向换算、沿线设施定位与分段汇总,并清楚 JTS 在这个领域的边界在哪里。

前置知识:第 4 章的 LineString/MultiLineString、第 11 章的长度与距离、第 19 章的坐标系与单位(本章会反复强调单位问题,建议先读那一章的单位部分)。

16.1 线性参考系统概念

16.1.1 路线、里程与事件

线性参考的核心概念只有三个:

  • 路线(route):一条有方向、有起点的线状要素,例如一条高速公路、一条管道、一段河道。
  • 里程(measure / chainage):沿路线从起点算起的距离,单位通常是米或公里。工程上常用“桩号”表示,如 K12+345.6 表示 12345.6 米。
  • 事件(event):发生在路线上的点或区间,例如事故点、桥梁、限速段、腐蚀区间。
一条路线上的里程与事件

  起点 K0+000                                             终点 K28+500
    ●───────●──────────●───────────────●───────────────●
    0      5000       12000           20000           28500  (米)
            │           │                │
         桥梁(5.0km)  事故(12.0km)     服务区(20.0km)

  事件表:
  ┌──────────┬──────────┬──────────────┐
  │ 事件类型  │ 桩号      │ 附加属性      │
  ├──────────┼──────────┼──────────────┤
  │ 桥梁      │ K5+000   │ 长度 120m     │
  │ 事故      │ K12+000  │ 伤亡 2 人     │
  │ 服务区    │ K20+000  │ 车位 200      │
  └──────────┴──────────┴──────────────┘

线性参考系统的价值在于“位置与几何解耦”:事件只需要记录“哪条路线 + 多少里程”,几何可以随时更新(改线、加密节点、纠正坐标),事件位置自动跟着变。这对线性资产的长期管理非常重要——公路每改一次线就重录一遍事故坐标是不可接受的。

16.1.2 JTS 的实现范围

JTS 的 linearref 是一个几何层工具包,它只回答“给定线几何与里程,坐标在哪 / 反过来”,不涉及:

能力 JTS 是否提供 说明
里程 ↔ 坐标换算 是 LengthIndexedLine、LocationIndexedLine
沿线取点/截线 是 extractPoint、extractLine
子线定位 是 indicesOf(Geometry)
沿线等距插点 是(需自己循环) extractPoint + LinearGeometryBuilder
路线表/度量表管理 否 需要数据库或 GeoTools/ArcGIS 等平台
M 值(Measure 坐标) 否 JTS 的 CoordinateXYM 存在,但 linearref 不读 M
改线后的历史里程迁移 否 需要业务层实现“里程匹配/重定位”
网络最短路径里程 否 需要图算法(JGraphT 等)
地理坐标系下的真实距离 否 JTS 不做投影,长度是平面欧氏距离

这张表非常重要:很多“LRS 需求”在 JTS 里只能完成 30%——几何变换那部分。剩下的路线模型、度量模型、事件表、里程迁移,需要自己在应用层或数据库层实现。

16.1.3 两套坐标:长度索引与位置索引

JTS 提供两套等价的定位方式,这是初学者最容易混淆的地方:

同一条线上的两个点,两种表示

  LengthIndexedLine:用"从起点走多远"表示
     index = 12.5  (米,或任意长度单位)
     → extractPoint(12.5) = (x, y)

  LocationIndexedLine:用"第几段 + 段内比例"表示
     LinearLocation(componentIndex=0, segmentIndex=1, segmentFraction=0.25)
     → 第 0 个部件、第 1 段(顶点1→顶点2)、段内 25% 处
     → getCoordinate() = (x, y)

  两者可以互相换算:
     长度 → LinearLocation:LengthLocationMap.getLocation(geom, length)
     位置 → 长度:LengthLocationMap.getLength(geom, loc)
维度 长度索引 位置索引
表示 double 里程 LinearLocation 三元组
依赖 线段长度(需要累加) 线段下标与比例(不依赖长度)
换算成本 每次从头遍历 O(n) 直接访问 O(1)
适合 业务里程、桩号、长度统计 内部算法、频繁定位、不关心长度
精度 受长度累加浮点误差影响 段内比例精确
类 LengthIndexedLine、LengthLocationMap LocationIndexedLine、LinearLocation

选择原则:对外用长度索引(用户说里程),对内用位置索引(算法要快)。两者之间的换算是本章的核心内容之一。

16.1.4 包结构

modules/core/src/main/java/org/locationtech/jts/linearref/
├── LinearLocation.java          位置索引(component + segment + fraction)
├── LengthIndexedLine.java       按长度定位的对外 API
├── LocationIndexedLine.java     按位置定位的对外 API
├── LengthLocationMap.java       长度 ↔ 位置 双向换算
├── LengthIndexOfPoint.java      点 → 长度的投影(包内)
├── LocationIndexOfPoint.java    点 → 位置的投影(包内)
├── LocationIndexOfLine.java     子线 → 起终位置(包内)
├── ExtractLineByLocation.java   按位置截线(包内)
├── LinearIterator.java          线性几何的段迭代器
└── LinearGeometryBuilder.java   沿线构建折线/多线

注意 LengthIndexOfPoint、LocationIndexOfPoint、LocationIndexOfLine、ExtractLineByLocation 都是包级私有,只能通过 LengthIndexedLine/LocationIndexedLine 间接使用。想直接调用它们必须把代码放在同一个包下(不推荐),或者复制逻辑。

16.1.5 输入几何的要求

LinearLocation 与 LinearIterator 的构造器会做类型检查:

if (! (linearGeom instanceof Lineal))
    throw new IllegalArgumentException("Lineal geometry is required");

因此输入必须是 LineString 或 MultiLineString(Lineal 接口的实现),不能是 GeometryCollection 里混着点/面,也不能是 Polygon(尽管面有边界线)。使用时先 LinearComponentExtracter 提取线组件,或直接传 LineString/MultiLineString。

另外两个隐含要求:

  1. 每个部件内部按顶点顺序单调:线性参考把“部件内第 i 段”视为从顶点 i 到 i+1,方向由顶点顺序决定。路线方向就是第一个部件的第一个顶点到最后一个部件的最后一个顶点。
  2. 不允许零长度部件参与换算:MultiLineString 里如果有 (10 0, 10 0) 这样的零长度线,长度换算会踩到“多个位置对应同一里程”的歧义,LengthLocationMap 的处理是把零长度部件跳过(resolveHigher 里循环 getLength() == 0 的部件)。如果业务允许零长度线,要自己定义归属规则。

16.2 LinearLocation:位置索引模型

16.2.1 三元组

LinearLocation 用三个值描述线上的一个位置:

public class LinearLocation implements Comparable {
    private int componentIndex;     // 第几个线部件(MultiLineString 的下标)
    private int segmentIndex;       // 部件内第几段(顶点 i → i+1)
    private double segmentFraction; // 段内比例 [0, 1]
}
LinearLocation(componentIndex=1, segmentIndex=2, segmentFraction=0.25)

  MULTILINESTRING 的部件 1:
   顶点0     顶点1     顶点2      顶点3
    ●─────────●─────────●──────────●
    seg0      seg1      seg2       seg3
                          │
                     fraction=0.25
                          ↓
                        ● 位置在 seg2 的 25% 处

三个值的边界语义:

  • segmentIndex 的范围是 [0, numPoints-1]。segmentIndex == numPoints-1 表示“最后一个顶点之后”,即部件终点。
  • segmentFraction 规范化为 [0, 1)(注意是左闭右开):normalize() 在 fraction 等于 1.0 时把它改成 0.0 并把 segmentIndex 加一。因此“段末”不会表示为 (i, 1.0),而是 (i+1, 0.0)。
  • componentIndex 可以为 numGeometries-1,此时配合 segmentIndex == numPoints-1 表示整条线的终点。

LinearLocation 还实现了 Comparable,比较顺序是 componentIndex → segmentIndex → segmentFraction,因此可以直接排序、做区间判断。

16.2.2 规范化规则

normalize() 的完整逻辑(源码):

private void normalize() {
    if (segmentFraction < 0.0) segmentFraction = 0.0;
    if (segmentFraction > 1.0) segmentFraction = 1.0;
    if (componentIndex < 0) {
        componentIndex = 0;
        segmentIndex = 0;
        segmentFraction = 0.0;
    }
    if (segmentIndex < 0) {
        segmentIndex = 0;
        segmentFraction = 0.0;
    }
    if (segmentFraction == 1.0) {
        segmentFraction = 0.0;
        segmentIndex += 1;
    }
}

这个“fraction 1.0 进位”的规则是很多 bug 的源头:如果你期望 new LinearLocation(0, 1, 1.0) 表示“第 1 段末尾”,实际得到的是 (0, 2, 0.0)——两者在几何上是同一个点,但下标不同,做区间比较时可能出错。构造 LinearLocation 时尽量用 [0,1) 的 fraction,需要表示终点时用 segmentIndex = numSegments 配合 fraction 0。

16.2.3 常用方法

方法 说明
getCoordinate(Geometry) 换算成本坐标
getSegment(Geometry) 返回所在 LineSegment(终点位置返回最后一段)
getSegmentLength(Geometry) 所在段长度
isVertex() fraction 是否为 0(即位置恰在顶点上)
isEndpoint(Geometry) 是否为整个部件的终点位置
toLowest(Geometry) 把“终点”表示转成“最后一段 fraction=1”的低位表示
clamp(Geometry) 把越界位置夹到合法范围
snapToVertex(Geometry, double minDistance) 离端点足够近时吸附到顶点
isOnSameSegment(LinearLocation) 两个位置是否在同一段(含相邻段的 0 比例)
isValid(Geometry) 检查下标/比例是否合法
compareTo(Object) 按 component → segment → fraction 比较
copy() / toLowest() 拷贝 / 转低位

toLowest 的存在是为了统一“终点”的两种表示。例如 LINESTRING (10 0, 20 0) 的终点可以表示为 (0, 1, 0.0)(第 1 段的起点)或 (0, 0, 1.0)(第 0 段的终点)。normalize 会强制前者,toLowest 会转成后者。ExtractLineByLocation 依赖这个转换来正确截取“包含终点”的区间。

16.2.4 equals / hashCode 的版本变化

1.21 之前,LinearLocation.equals 与 hashCode 的实现与 compareTo 不完全一致,导致把 LinearLocation 放进 HashSet/HashMap 时可能出现“compareTo 相等但 equals 不等”的问题。1.21 的 #1184 统一了 equals/hashCode/compareTo 的语义,#1229 又补了 NaN 的处理:

  • 两个 LinearLocation 的 compareTo == 0 时,equals 必须为 true;
  • 相等的对象 hashCode 必须相等;
  • NaN 只与自身相等(Double.compare(NaN, NaN) == 0)。

如果你的代码(或依赖的旧版本 JTS)需要把 LinearLocation 放进哈希容器,建议升级到 1.21+;否则用 List 或自定义 key。

16.2.5 完整示例

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.linearref.LinearLocation;
import org.locationtech.jts.linearref.LocationIndexedLine;

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

        LinearLocation l0 = indexedLine.indexOf(new Coordinate(0, 0));
        LinearLocation l5 = indexedLine.indexOf(new Coordinate(5, 0));
        LinearLocation l10 = indexedLine.indexOf(new Coordinate(10, 0));
        LinearLocation l25 = indexedLine.indexOf(new Coordinate(25, 0));

        System.out.println("l0=" + l0);
        System.out.println("l5=" + l5);
        System.out.println("l10=" + l10);
        System.out.println("l25=" + l25);
        System.out.println("l5 与 l0 同段=" + l5.isOnSameSegment(l0));
        System.out.println("l5 与 l10 同段=" + l5.isOnSameSegment(l10));
        System.out.println("l5 坐标=" + l5.getCoordinate(line));
        System.out.println("l10 是顶点=" + l10.isVertex());
        System.out.println("l5 在 l10 之前=" + (l5.compareTo(l10) < 0));
        System.out.println("截取 l5~l25=" + indexedLine.extractLine(l5, l25));
    }
}

示例输出:

l0=LinearLoc[0, 0, 0.0]
l5=LinearLoc[0, 0, 0.5]
l10=LinearLoc[0, 1, 0.0]
l25=LinearLoc[0, 2, 0.5]
l5 与 l0 同段=true
l5 与 l10 同段=true
l5 坐标=(5.0, 0.0, NaN)
l10 是顶点=true
l5 在 l10 之前=true
截取 l5~l25=LINESTRING (5 0, 10 0, 20 0, 25 0)

两个细节:

  • l5 与 l10 同段=true:isOnSameSegment 把“第 0 段 fraction 0.5”与“第 1 段 fraction 0.0”视为同段(相邻段共享顶点)。判据是源码里的三条分支:segmentIndex 相同、或相邻且 fraction 为 0。
  • l10=(0,1,0.0):坐标 (10,0) 是顶点 1,规范化后落在“第 1 段的起点”,不是“第 0 段的终点”。这就是 16.2.2 讲的进位规则。

16.3 LengthIndexedLine:按里程定位

16.3.1 构造与范围

public class LengthIndexedLine {
    public LengthIndexedLine(Geometry linearGeom);
    public double getStartIndex();   // 恒为 0
    public double getEndIndex();     // linearGeom.getLength()
    public boolean isValidIndex(double index);
    public double clampIndex(double index);
}

getEndIndex() 就是 Geometry.getLength(),也就是所有部件长度的和。注意这是平面欧氏长度:Geometry.getLength() 对 LineString 累加 Coordinate.distance,完全忽略 Z;对 MultiLineString 累加各部件长度。地理坐标(经纬度)下算出来的“长度”单位是度,没有物理意义。

16.3.2 投影:点 → 里程

public double indexOf(Coordinate pt);          // 最近位置的里程
public double project(Coordinate pt);          // 与 indexOf 完全相同(别名)
public double indexOfAfter(Coordinate pt, double minIndex);  // 里程 >= minIndex 的最近位置

indexOf 与 project 是别名,源码里都调 LengthIndexOfPoint.indexOf(linearGeom, pt)。内部用 LinearIterator 遍历每一段,计算点到线段的距离与投影比例,取距离最小的段;距离相同时取先遍历到的(即里程较小的)。indexOfAfter 用于“从某个里程之后找最近事件”,典型场景是“下一座桥在哪”。

一个重要的语义:投影点不一定落在线上。indexOf 返回的是“线上最近点对应的里程”,点到线的垂足如果落在线段外,会被夹到端点。例如查询点在线外 5 米,返回的里程对应的是线上最近点,而不是“投影失败”。

16.3.3 按里程取点

public Coordinate extractPoint(double index);
public Coordinate extractPoint(double index, double offsetDistance);

第一个重载返回线上该里程处的点。第二个重载在点的基础上再沿线路法向偏移 offsetDistance:正数在路线前进方向的左侧(JTS 约定,与 LineSegment.pointAlongOffset 一致),负数在右侧。

// LINESTRING (0 0, 20 20),方向从左下到右上
LengthIndexedLine line = new LengthIndexedLine(
    new WKTReader().read("LINESTRING (0 0, 20 20)"));
System.out.println(line.extractPoint(0, 1));    // (-0.7071, 0.7071) 左侧
System.out.println(line.extractPoint(0, -1));   // (0.7071, -0.7071) 右侧

示例输出:

(-0.7071067811865475, 0.7071067811865475, NaN)
(0.7071067811865475, -0.7071067811865475, NaN)

偏移量是平面距离,单位与坐标单位一致。地理坐标系下偏移 0.001 度不是“1 毫米”,而是约 100 米。这点在 16.10 节还会展开。

16.3.4 截取线段

public Geometry extractLine(double startIndex, double endIndex);

行为要点:

  1. 自动 clamp:超出 [0, getEndIndex()] 的索引先夹到范围内。
  2. 负索引:-1 表示“终点前 1 个单位”,-getLength() 表示起点。这是 positiveIndex 的规则:index < 0 时用 length + index。
  3. 起终点相同时返回一个点状的 LineString(首尾相同坐标),不返回空。
  4. start > end 时自动反向:返回的线从 end 指向 start(几何上反向),ExtractLineByLocation.extract 用 reverse() 实现。
  5. 返回类型:单部件返回 LineString,多部件返回 MultiLineString(LinearGeometryBuilder.getGeometry() 依据线数 buildGeometry)。
Geometry line = new WKTReader().read(
    "MULTILINESTRING ((0 0, 10 0), (20 0, 25 0, 30 0))");
LengthIndexedLine lil = new LengthIndexedLine(line);
System.out.println(lil.extractLine(-9, -1));
System.out.println(lil.extractLine(-100, 100));
System.out.println(lil.extractLine(10, -1));

示例输出:

LINESTRING (21 0, 25 0, 29 0)
MULTILINESTRING ((0 0, 10 0), (20 0, 25 0, 30 0))
LINESTRING (20 0, 25 0, 29 0)

读法:总长 20(第一段 10,第二段 10,几何间隙不计入)。-9 表示 20 + (-9) = 11,-1 表示 19,所以截取 11~19,对应第二部件(里程 10~20 映射到坐标 (20,0)~(30,0))上 21~29 的部分,输出 LINESTRING (21 0, 25 0, 29 0);(-100, 100) 被夹到 0~20,返回整条线;(10, -1) 即 10~19,起点 10 是部件边界,输出第二个部件 (20,0)~(29,0)。

这里有一个容易误解的地方:里程 10 在几何上对应“第一部件终点 (10,0)”,而第二部件起点 (20,0) 对应的里程仍然是 10——JTS 把多部件按顺序视为“首尾相连”,部件之间的坐标间隙不计入里程。实测 extractPoint(10.001) 返回 (20.000999999999998, 0.0),也就是说 0.001 单位的里程直接走在第二部件的方向上。这个语义对“路线分段存储”很方便(部件之间不需要连接线),但对“几何上真的有间隙”的数据很危险:里程连续、地图上却会“跳”。如果部件之间确实断开,应显式插入连接线,或在业务层记录“跳变区间”。

16.3.5 子线定位

public double[] indicesOf(Geometry subLine);

返回 [startIndex, endIndex],用于把一段已知子线换算成里程区间。实现是取子线首点投影、再取末点投影(indexOfAfter 保证末点不早于首点)。它不验证子线是否真的落在母线上:如果子线偏离母线很远、或与母线方向相反,返回的是“首末点投影里程”,中间是否重合需要自己校验。

Geometry route = new WKTReader().read(
    "MULTILINESTRING ((0 0, 10 0), (20 0, 25 0, 30 0))");
Geometry sub = new WKTReader().read(
    "MULTILINESTRING ((1 0, 10 0), (20 0, 25 0))");
LengthIndexedLine lil = new LengthIndexedLine(route);
double[] idx = lil.indicesOf(sub);
System.out.println(java.util.Arrays.toString(idx));   // [1.0, 15.0]

示例输出:

[1.0, 15.0]

子线首点 (1,0) 在母线上里程 1,末点 (25,0) 在母线上里程 15(10 + 5),因此区间是 [1, 15]。

16.3.6 完整示例

import java.util.Arrays;

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.linearref.LengthIndexedLine;

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

        System.out.println("总长=" + line.getLength()
            + ", start=" + lil.getStartIndex() + ", end=" + lil.getEndIndex());
        System.out.println("project(5,5)=" + lil.project(new Coordinate(5, 5)));
        System.out.println("project(15,5)=" + lil.project(new Coordinate(15, 5)));
        System.out.println("extractPoint(5)=" + lil.extractPoint(5));
        System.out.println("extractPoint(15)=" + lil.extractPoint(15));
        System.out.println("extractLine(5,15)=" + lil.extractLine(5, 15));
        System.out.println("extractLine(15,5)=" + lil.extractLine(15, 5));

        Geometry sub = reader.read("LINESTRING (2 0, 10 5)");
        System.out.println("indicesOf=" + Arrays.toString(lil.indicesOf(sub)));
        System.out.println("isValid(-1)=" + lil.isValidIndex(-1)
            + ", clamp(-1)=" + lil.clampIndex(-1));
    }
}

示例输出:

总长=20.0, start=0.0, end=20.0
project(5,5)=5.0
project(15,5)=15.0
extractPoint(5)=(5.0, 0.0, NaN)
extractPoint(15)=(10.0, 5.0, NaN)
extractLine(5,15)=LINESTRING (5 0, 10 0, 10 5)
extractLine(15,5)=LINESTRING (10 5, 10 0, 5 0)
indicesOf=[2.0, 15.0]
isValid(-1)=false, clamp(-1)=0.0

LINESTRING (0 0, 10 0, 10 10) 总长 20:第一段 10,第二段 10。(5,5) 的投影落在第一段中点(里程 5),(15,5) 的投影落在第二段中点(里程 15)。extractLine(15,5) 返回反向线,这是 JTS 的明确行为,不是 bug。

16.3.7 索引语义速查

输入 行为
index < 0 视为 length + index(从终点倒着算)
index > length extractPoint 夹到终点;extractLine 先 clamp
index == getEndIndex() 返回线终点
startIndex == endIndex 返回退化线(两点相同)
startIndex > endIndex 返回反向线
多部件间隙 不计入里程,部件按顺序首尾相连(几何上可能跳变)
零长度部件 长度换算时跳过该部件

这张表建议贴在代码注释里。实际项目里“里程越界”与“多部件间隙”是两类最高频的问题,前者靠 isValidIndex + clampIndex 防御,后者靠数据模型规避。

16.4 LocationIndexedLine:按位置定位

16.4.1 与长度索引的分工

LocationIndexedLine 是 LengthIndexedLine 的“位置版”:API 形状几乎一样,但索引类型从 double 换成 LinearLocation。

public class LocationIndexedLine {
    public LocationIndexedLine(Geometry linearGeom);
    public Coordinate extractPoint(LinearLocation index);
    public Coordinate extractPoint(LinearLocation index, double offsetDistance);
    public Geometry extractLine(LinearLocation startIndex, LinearLocation endIndex);
    public LinearLocation indexOf(Coordinate pt);
    public LinearLocation project(Coordinate pt);
    public LinearLocation indexOfAfter(Coordinate pt, LinearLocation minIndex);
    public LinearLocation[] indicesOf(Geometry subLine);
    public LinearLocation getStartIndex();
    public LinearLocation getEndIndex();
    public boolean isValidIndex(LinearLocation index);
    public LinearLocation clampIndex(LinearLocation index);
}

两者的行为对照:

操作 LengthIndexedLine LocationIndexedLine
起点 0.0 new LinearLocation(),即 (0,0,0.0)
终点 getLength() LinearLocation.getEndLocation(geom)
定位 每次从头累加长度 直接得到段号与比例
越界处理 clampIndex(double) clampIndex(LinearLocation)
相等判断 double ==(浮点风险) compareTo / equals(1.21 起一致)
反向截取 支持 支持

从性能角度,LocationIndexedLine 的优势是不需要累加长度:indexOf 仍然要遍历(找最近段),但拿到位置后 extractPoint/extractLine 都是 O(1)~O(k) 的直接访问;而 LengthIndexedLine 的每次 extractPoint 都要从起点遍历到目标里程(LengthLocationMap.getLocationForward 内部用 LinearIterator 累加)。批量操作时这个差异会被放大:沿线插 10000 个点,用长度索引是 O(n×m),用位置索引可以做到 O(n + m)。

16.4.2 完整示例

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.linearref.LinearLocation;
import org.locationtech.jts.linearref.LocationIndexedLine;

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

        LinearLocation start = indexedLine.getStartIndex();
        LinearLocation end = indexedLine.getEndIndex();
        System.out.println("start=" + start + ", end=" + end);

        LinearLocation l5 = indexedLine.indexOf(new Coordinate(5, 0));
        LinearLocation l10 = indexedLine.indexOf(new Coordinate(10, 0));
        System.out.println("位置(5,0)=" + l5 + ", 坐标=" + indexedLine.extractPoint(l5));

        // 位置偏移:左侧 2 个单位
        System.out.println("左侧偏移=" + indexedLine.extractPoint(l5, 2.0));

        // 截取到终点
        System.out.println("截取 5 到终点=" + indexedLine.extractLine(l5, end));

        // 越界位置 clamp
        LinearLocation beyond = new LinearLocation(0, 99, 0.0);
        System.out.println("越界=" + beyond + ", clamp 后=" + indexedLine.clampIndex(beyond));
        System.out.println("isValid(越界)=" + indexedLine.isValidIndex(beyond));
    }
}

示例输出:

start=LinearLoc[0, 0, 0.0], end=LinearLoc[0, 3, 0.0]
位置(5,0)=LinearLoc[0, 0, 0.5], 坐标=(5.0, 0.0, NaN)
左侧偏移=(5.0, 2.0, NaN)
截取 5 到终点=LINESTRING (5 0, 10 0, 20 0, 30 0)
越界=LinearLoc[0, 99, 0.0], clamp 后=LinearLoc[0, 3, 0.0]
isValid(越界)=false

左侧偏移的结果是 (5.0, 2.0):路线沿 +x 方向,左侧是 +y 方向,偏移 2 得到 (5,2)。getEndIndex() 返回 LinearLoc[0, 3, 0.0](segmentIndex = 顶点数 - 1 = 3),不是 (0,2,1.0),这正是 16.2.2 的规范化规则。

16.4.3 什么时候必须用位置索引

以下场景建议用 LocationIndexedLine:

  1. 反复对同一位置取值:先 indexOf 一次拿到 LinearLocation,之后 extractPoint/extractLine 反复用,不必重新累加里程。
  2. 位置相等性判断:LinearLocation.equals 语义明确;double 里程做相等判断需要人为给 epsilon。
  3. 算法内部传递位置:例如自定义的“沿线采样器”把位置作为状态保存,位置索引的 compareTo 可以直接排序。
  4. 零长度段/重复顶点:位置模型对零长度段的表达更自然(fraction 为 0),长度模型下零长度段会导致同一里程对应多个位置。

反过来,只要用户输入的是“桩号/里程”,就应该用 LengthIndexedLine——把用户里程换成 LinearLocation 再换回来只会引入误差。

16.4.4 两者互转

LengthIndexedLine lil = new LengthIndexedLine(line);
LocationIndexedLine loc = new LocationIndexedLine(line);

// 长度 -> 位置
double chainage = 1234.5;
LinearLocation location = LengthLocationMap.getLocation(line, chainage);
// 位置 -> 长度
double back = LengthLocationMap.getLength(line, location);
System.out.println(chainage + " -> " + location + " -> " + back);

里程越界时不会抛异常,而是夹到终点。以 LINESTRING (0 0, 100 0) 为例:

LengthIndexedLine lil = new LengthIndexedLine(
    new WKTReader().read("LINESTRING (0 0, 100 0)"));
System.out.println(lil.extractPoint(1234.5));   // (100.0, 0.0, NaN)

LengthIndexedLine.extractPoint 内部会 clamp 到终点,所以越界里程不会抛异常,而是给出终点坐标。“不抛异常”不等于“数据正确”:业务上应先用 isValidIndex 判断,越界的里程要么是数据错误,要么是路线版本不一致,应该报警而不是静默夹取。

16.5 LengthLocationMap 与 LinearIterator

16.5.1 LengthLocationMap:双向换算器

LengthLocationMap 是长度 ↔ 位置的桥梁,全部是实例方法(通过静态方法内部 new 一个实例):

public class LengthLocationMap {
    public static LinearLocation getLocation(Geometry linearGeom, double length);
    public static LinearLocation getLocation(Geometry linearGeom, double length, boolean resolveLower);
    public static double getLength(Geometry linearGeom, LinearLocation loc);

    public LinearLocation getLocation(double length);
    public LinearLocation getLocation(double length, boolean resolveLower);
    public double getLength(LinearLocation loc);
}

resolveLower 参数控制“恰好落在部件终点”时返回低位还是高位:

  • resolveLower = true(默认):返回低位表示,即 (component, numSegments, 0.0);
  • resolveLower = false:返回高位表示,即下一个部件的 (component+1, 0, 0.0),并跳过零长度部件。
Geometry line = reader.read(
    "MULTILINESTRING ((0 0, 10 0), (20 0, 25 0, 30 0))");
LengthLocationMap map = new LengthLocationMap(line);
System.out.println("loc@0=" + map.getLocation(0));
System.out.println("loc@10=" + map.getLocation(10));
System.out.println("loc@10(resolveLower=false)=" + map.getLocation(10, false));
System.out.println("loc@25=" + map.getLocation(25));
System.out.println("loc@-5=" + map.getLocation(-5));
System.out.println("length(loc@15)=" + map.getLength(map.getLocation(15)));

示例输出:

loc@0=LinearLoc[0, 0, 0.0]
loc@10=LinearLoc[0, 1, 0.0]
loc@10(resolveLower=false)=LinearLoc[1, 0, 0.0]
loc@25=LinearLoc[1, 2, 0.0]
loc@-5=LinearLoc[1, 3, 0.0]
length(loc@15)=15.0

逐行解释:总长 20(第一个部件 10 + 第二个部件 10,几何间隙不计入)。里程 0 返回 (0,0,0.0);里程 10 是第一个部件终点,默认返回 (0,1,0.0)(低位),resolveLower=false 返回 (1,0,0.0)(下一部件起点);里程 25 越界,被夹到终点 (1,2,0.0);负里程 -5 按 20-5=15 处理,落在第二个部件 5 米处,实测返回 LinearLoc[1, 1, 0.0](第二个部件的第 1 段起点,即整体中点 (25,0) 对应的位置表示之一)。

这个例子说明两点:一是总长度按部件长度累加,与几何间隙无关;二是边界值的精确位置表示可能依赖实现(同一几何位置可能落在“前一段的终点”或“后一段的起点”),业务代码不要对 segmentIndex 的具体值做硬编码假设,用 compareTo 和 getLength 检验即可。

16.5.2 LinearIterator:段迭代器

LinearIterator 把 LineString/MultiLineString 展开成“段序列”,逐段访问:

public class LinearIterator {
    public LinearIterator(Geometry linear);
    public LinearIterator(Geometry linear, LinearLocation start);
    public LinearIterator(Geometry linearGeom, int componentIndex, int vertexIndex);

    public boolean hasNext();
    public void next();
    public boolean isEndOfLine();
    public int getComponentIndex();
    public int getVertexIndex();
    public LineString getLine();
    public Coordinate getSegmentStart();
    public Coordinate getSegmentEnd();   // 部件终点时返回 null
}

它的遍历语义是线性参考包内所有算法的共同基础:

LINESTRING (0 0, 10 0, 10 10) 的迭代序列

  componentIndex=0, vertexIndex=0   segment (0,0)->(10,0)   isEndOfLine=false
  componentIndex=0, vertexIndex=1   segment (10,0)->(10,10) isEndOfLine=false
  componentIndex=0, vertexIndex=2   segmentStart=(10,10)    isEndOfLine=true

MULTILINESTRING ((0 0, 10 0), (20 0, 30 0)) 的迭代序列

  (0,0)  seg (0,0)->(10,0)
  (0,1)  部件终点,isEndOfLine=true,segmentEnd=null
  (1,0)  seg (20,0)->(30,0)
  (1,1)  部件终点,isEndOfLine=true

注意最后一步 vertexIndex == numPoints-1 时 segmentEnd() 返回 null,这是“部件结束”的信号。依赖 getSegmentEnd() 的代码必须先判 isEndOfLine(),否则 NPE。

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.linearref.LinearIterator;

public class LinearIteratorDemo {
    public static void main(String[] args) throws Exception {
        Geometry line = new WKTReader().read(
            "MULTILINESTRING ((0 0, 10 0), (20 0, 30 0))");
        LinearIterator it = new LinearIterator(line);
        while (it.hasNext()) {
            if (it.isEndOfLine()) {
                System.out.println("部件终点 comp=" + it.getComponentIndex()
                    + " vertex=" + it.getVertexIndex());
            } else {
                Coordinate p0 = it.getSegmentStart();
                Coordinate p1 = it.getSegmentEnd();
                System.out.println("段 comp=" + it.getComponentIndex()
                    + " v=" + it.getVertexIndex() + " " + p0 + "->" + p1);
            }
            it.next();
        }
    }
}

示例输出:

段 comp=0 v=0 (0.0, 0.0, NaN)->(10.0, 0.0, NaN)
部件终点 comp=0 vertex=1
段 comp=1 v=0 (20.0, 0.0, NaN)->(30.0, 0.0, NaN)
部件终点 comp=1 vertex=1

16.5.3 用 LinearIterator 做单次遍历统计

LengthIndexedLine.extractPoint 每次调用都从起点遍历,批量场景下用 LinearIterator 单次遍历更高效。下面例子统计“每个部件的长度与斜率分段”:

import java.util.ArrayList;
import java.util.List;

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.linearref.LinearIterator;

public class SegmentStatsDemo {
    public static void main(String[] args) throws Exception {
        Geometry line = new WKTReader().read(
            "MULTILINESTRING ((0 0, 10 0, 10 10), (30 0, 40 0))");
        LinearIterator it = new LinearIterator(line);

        double total = 0;
        int segmentCount = 0;
        List<Double> lengths = new ArrayList<Double>();
        while (it.hasNext()) {
            if (!it.isEndOfLine()) {
                double len = it.getSegmentStart().distance(it.getSegmentEnd());
                total += len;
                segmentCount++;
                lengths.add(len);
            }
            it.next();
        }
        System.out.println("段数=" + segmentCount + ", 总长=" + total);
        System.out.println("各段长度=" + lengths);
        System.out.println("geometry.length=" + line.getLength());
    }
}

示例输出:

段数=3, 总长=30.0
各段长度=[10.0, 10.0, 10.0]
geometry.length=30.0

16.6 截线与子线定位

16.6.1 ExtractLineByLocation 的算法

ExtractLineByLocation 是包级私有的截线实现,LengthIndexedLine.extractLine 与 LocationIndexedLine.extractLine 最终都调它:

class ExtractLineByLocation {
    public static Geometry extract(Geometry line, LinearLocation start, LinearLocation end);
    public Geometry extract(LinearLocation start, LinearLocation end) {
        if (end.compareTo(start) < 0) {
            return reverse(computeLinear(end, start));  // 反向截取
        }
        return computeLinear(start, end);
    }
}

computeLinear 的关键步骤:

  1. 用 LinearGeometryBuilder 收集顶点,开启 setFixInvalidLines(true)(单点线补成两点线)。
  2. 若起点不是顶点,先 builder.add(start.getCoordinate(line))。
  3. 用 LinearIterator(line, start) 从起点所在段开始遍历,直到位置超过 end。
  4. 遇到 isEndOfLine() 时 builder.endLine(),把当前部件收尾(多部件会输出 MultiLineString 的多个部件)。
  5. 若终点不是顶点,补上终点坐标。
截线过程(示意)

  原线: ●────●────●────●────●
              s         e
  起点 s 在段中间、终点 e 在段中间
  => 结果: 先加 s 坐标,再加 s 所在段之后的顶点,最后加 e 坐标

  注意:s 与 e 之间的原始顶点都必须保留,
        否则结果的几何形状会变(不是"抽稀")

16.6.2 截线的边界行为

情形 结果
start == end 返回首尾相同的退化 LineString(两点)
start > end 返回反向线
起点/终点在段中间 结果以计算出的点开始/结束,不含原始段端点
多部件 返回 MultiLineString,部件顺序与原线一致(反向时逆序)
跨部件间隙 间隙不产生几何,结果部件间不连续
截取范围不足一个点 LinearGeometryBuilder 的 fixInvalidLines 会补成两点线

16.6.3 子线定位的陷阱

indicesOf(Geometry subLine) 只取子线的首点与末点做投影,因此:

  1. 子线必须与母线共线或近似共线,否则首末投影之间的里程区间可能与子线实际形状无关。
  2. 子线方向必须与母线一致,否则 indexOfAfter(endPt, startLoc) 可能返回远大于预期的位置(因为它要求“不早于起点”)。
  3. 子线可能跨越多个部件,indicesOf 只返回两个端点里程,中间的部件归属需要自己判断。
  4. 首末点重合的闭环会被当作零长度子线处理(源码判断 subLine.getLength() == 0.0)。

正确的用法是:先用 indicesOf 得到候选区间,再用 extractLine 截出该区间,最后用 equalsExact 或 Hausdorff 距离验证子线确实落在母线上:

double[] idx = lil.indicesOf(subLine);
Geometry candidate = lil.extractLine(idx[0], idx[1]);
boolean consistent = candidate.equalsExact(subLine.norm(), 1e-6)
    || candidate.norm().equalsExact(subLine.norm(), 1e-6);
System.out.println("子线一致=" + consistent);

equalsExact 对顶点顺序敏感,所以先用 norm() 规范化(统一方向与起点)。这个校验在“事件区间落图”场景里几乎是必须的:用户上传的区间线可能方向反了、起终点飘了,直接换算里程会得到看似合理但完全错误的区间。

16.6.4 完整示例

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

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

        System.out.println("全长=" + lil.getEndIndex());
        System.out.println("0~5=" + lil.extractLine(0, 5));
        System.out.println("5~15=" + lil.extractLine(5, 15));
        System.out.println("15~5=" + lil.extractLine(15, 5));
        System.out.println("10~10=" + lil.extractLine(10, 10));

        Geometry sub = reader.read("LINESTRING (12 0, 12 6)");
        double[] idx = lil.indicesOf(sub);
        System.out.println("子线里程=" + java.util.Arrays.toString(idx));
    }
}

示例输出:

全长=30.0
0~5=LINESTRING (0 0, 5 0)
5~15=LINESTRING (5 0, 10 0, 10 5)
15~5=LINESTRING (10 5, 10 0, 5 0)
10~10=LINESTRING (10 0, 10 0)
子线里程=[12.0, 18.0]

读法:LINESTRING (0 0, 10 0, 10 10, 20 10) 三段各长 10,总长 30。5~15 从第一段中间到第二段中间,输出 (5 0, 10 0, 10 5);10~10 是退化线 (10 0, 10 0);子线 (12 0, 12 6) 与路线在 (12,0)~(12,6) 处不重合(路线在 x=10 处竖直),但 indicesOf 仍返回首末投影里程 [12, 18]——这正是 16.6.3 说的“不校验共线”。这个例子故意保留了陷阱:子线并不在路线上,但换算依然给出结果。

16.7 沿线插点与 LinearGeometryBuilder

16.7.1 LinearGeometryBuilder 的作用

LinearGeometryBuilder 负责“按顺序喂坐标,最后拼成线”:

public class LinearGeometryBuilder {
    public LinearGeometryBuilder(GeometryFactory geomFact);
    public void setIgnoreInvalidLines(boolean ignoreInvalidLines);
    public void setFixInvalidLines(boolean fixInvalidLines);
    public void add(Coordinate pt);                       // 允许重复点
    public void add(Coordinate pt, boolean allowRepeatedPoints);
    public void endLine();
    public Coordinate getLastCoordinate();
    public Geometry getGeometry();
}

三个行为开关:

开关 默认 作用
ignoreInvalidLines false 坐标不足 2 个时丢弃该线;false 时抛异常
fixInvalidLines false 坐标不足 2 个时补成两点线 (p, p)
allowRepeatedPoints true 是否允许连续重复点

endLine() 表示“当前部件结束”,之后 add 的坐标属于新部件;getGeometry() 会先自动 endLine(),再按线数返回 LineString 或 MultiLineString。

16.7.2 沿线等距插点

最典型的用法是按固定间隔取点(例如每 100 米一个桩号点、每 10 米一个采样点):

import java.util.ArrayList;
import java.util.List;

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.linearref.LengthIndexedLine;
import org.locationtech.jts.linearref.LinearGeometryBuilder;

public class StationPointsDemo {
    public static void main(String[] args) throws Exception {
        Geometry route = new WKTReader().read("LINESTRING (0 0, 10 0, 20 0)");
        LengthIndexedLine lil = new LengthIndexedLine(route);
        GeometryFactory gf = new GeometryFactory();

        // 每 5 个单位一个点
        List<Coordinate> points = new ArrayList<Coordinate>();
        for (double d = 0; d <= lil.getEndIndex(); d += 5.0) {
            points.add(lil.extractPoint(d));
        }
        System.out.println("点数=" + points.size());
        System.out.println("点=" + points);

        // 用 LinearGeometryBuilder 沿线构造折线(含偏移)
        LinearGeometryBuilder builder = new LinearGeometryBuilder(gf);
        builder.add(lil.extractPoint(0));
        builder.add(lil.extractPoint(10));
        builder.add(lil.extractPoint(20));
        System.out.println("构造线=" + builder.getGeometry());
    }
}

示例输出:

点数=5
点=[(0.0, 0.0, NaN), (5.0, 0.0, NaN), (10.0, 0.0, NaN), (15.0, 0.0, NaN), (20.0, 0.0, NaN)]
构造线=LINESTRING (0 0, 10 0, 20 0)

注意循环条件用 d <= endIndex:如果总长不是间隔的整数倍,最后一个点会落在终点之前,终点本身不会被采到。需要“包含终点”时,循环后再补一个 extractPoint(endIndex),并注意去重(终点可能与前一个点重合)。

16.7.3 高效版:单次遍历 + 插值

extractPoint 每次从头遍历,10 万个点会退化为 O(n×m)。高效做法是用 LinearIterator 走一遍,在每段内部按剩余距离插值:

import java.util.ArrayList;
import java.util.List;

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.linearref.LinearIterator;

public class DensifyByDistance {
    /** 沿线每 interval 距离取一个点,包含起点与终点 */
    public static List<Coordinate> sample(Geometry line, double interval) {
        List<Coordinate> result = new ArrayList<Coordinate>();
        LinearIterator it = new LinearIterator(line);
        double next = 0.0;          // 下一个采样点的里程
        double walked = 0.0;        // 已累计里程

        while (it.hasNext()) {
            if (!it.isEndOfLine()) {
                Coordinate p0 = it.getSegmentStart();
                Coordinate p1 = it.getSegmentEnd();
                double segLen = p0.distance(p1);
                while (next <= walked + segLen + 1e-12) {
                    if (segLen == 0) break;
                    double frac = (next - walked) / segLen;
                    result.add(new Coordinate(
                        p0.x + (p1.x - p0.x) * frac,
                        p0.y + (p1.y - p0.y) * frac));
                    next += interval;
                }
                walked += segLen;
            }
            it.next();
        }
        return result;
    }

    public static void main(String[] args) throws Exception {
        Geometry line = new WKTReader().read("LINESTRING (0 0, 10 0, 10 10)");
        System.out.println(sample(line, 4.0));
    }
}

示例输出:

[(0.0, 0.0, NaN), (4.0, 0.0, NaN), (8.0, 0.0, NaN), (10.0, 2.0, NaN), (10.0, 6.0, NaN), (10.0, 10.0, NaN)]

这段代码的关键是 walked + segLen 的浮点比较用 1e-12 容差,避免“下一采样点恰好落在段末端”时因浮点误差漏点。实际项目里还应对 segLen == 0(重复顶点)做跳过处理,否则 frac 会除零。

16.7.4 用偏移构造平行线

extractPoint(index, offsetDistance) 可以生成平行线,但要注意只对平缓曲线有效:在急弯处,固定偏移距离会导致自交(内侧)或尖刺(外侧)。JTS 有专门的 OffsetCurve(第 10 章)处理这个问题:

// 不推荐:沿急弯偏移会自交
for (double d = 0; d <= lil.getEndIndex(); d += 1.0) {
    builder.add(lil.extractPoint(d, 10.0));
}

// 推荐:用 OffsetCurve(第 10 章)
Geometry offset = org.locationtech.jts.operation.buffer.OffsetCurve.getCurve(line, 10.0);

OffsetCurve.getCurve 的签名是 getCurve(Geometry line, double distance)(1.20 现行 API)。它内部处理了自交清理与圆角/斜接,比逐点偏移稳健得多。逐点偏移的正确用途是“在平直段上按里程生成标注点/采样点”,不是生成平行线。

16.8 里程、桩号与分段统计

16.8.1 桩号格式

工程上很少直接写“12345.6 米”,而是写桩号 K12+345.60:

  • K 后面的数字是公里数;
  • + 后面的数字是米数,通常补足 3 位整数、2 位小数;
  • K12+345.60 = 12345.60 米。

换算函数很简单,但有两个坑:负里程与取整方式。里程一般不出现负数;米数部分必须用 floor 而不是截断(对负数 (long) 截断会向零取整,结果错位)。

public final class Chainage {
    private Chainage() { }

    /** 米 -> 桩号,例如 12345.6 -> "K12+345.60" */
    public static String format(double metres) {
        if (Double.isNaN(metres)) return "K?";
        if (metres < 0) return "-" + format(-metres);
        long km = (long) Math.floor(metres / 1000.0);
        double rest = metres - km * 1000.0;
        return String.format("K%d+%06.2f", km, rest);
    }

    /** 桩号 -> 米,例如 "K12+345.60" -> 12345.6 */
    public static double parse(String chainage) {
        String s = chainage.trim().toUpperCase();
        if (!s.startsWith("K")) {
            throw new IllegalArgumentException("桩号必须以 K 开头: " + chainage);
        }
        int plus = s.indexOf('+');
        if (plus < 0) {
            throw new IllegalArgumentException("桩号缺少 +: " + chainage);
        }
        double km = Double.parseDouble(s.substring(1, plus));
        double m = Double.parseDouble(s.substring(plus + 1));
        return km * 1000.0 + m;
    }

    public static void main(String[] args) {
        System.out.println(format(12345.6));      // K12+345.60
        System.out.println(format(0));            // K0+000.00
        System.out.println(format(999.99));       // K0+999.99
        System.out.println(format(1234567.89));   // K1234+567.89
        System.out.println(parse("K12+345.60"));  // 12345.6
    }
}

示例输出:

K12+345.60
K0+000.00
K0+999.99
K1234+567.89
12345.6

注意 999.99 输出为 K0+999.99 而不是 K1-000.01:桩号系统里“公里数 + 米数”是拼接而非严格小数。如果业务规范要求“米数部分必须小于 1000”,上面实现满足;如果允许进位写法(K1+000.00 表示 1000 米整),则解析时要允许 999.99 这类值,不能假设米数 < 1000。

16.8.2 事件定位:桩号 → 坐标

一条路线上有若干事件(桥梁、事故、限速段),事件表只存桩号。把事件落到地图上就是“桩号 → 里程 → 坐标”:

import java.util.ArrayList;
import java.util.List;

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.linearref.LengthIndexedLine;

public class EventLocator {
    static final class Event {
        final String id;
        final double chainage;   // 米
        Event(String id, double chainage) {
            this.id = id;
            this.chainage = chainage;
        }
    }

    public static void main(String[] args) throws Exception {
        Geometry route = new WKTReader().read(
            "LINESTRING (0 0, 100 0, 100 100, 200 100)");
        LengthIndexedLine lil = new LengthIndexedLine(route);
        GeometryFactory gf = new GeometryFactory();

        List<Event> events = new ArrayList<Event>();
        events.add(new Event("桥梁-1", 150));
        events.add(new Event("事故-1", 250));
        events.add(new Event("服务区-1", 400));

        System.out.println("路线总长=" + lil.getEndIndex());
        for (Event e : events) {
            boolean valid = lil.isValidIndex(e.chainage);
            double clamped = lil.clampIndex(e.chainage);
            Point p = gf.createPoint(lil.extractPoint(clamped));
            System.out.println(e.id + " 桩号=" + e.chainage
                + " 有效=" + valid + " 坐标=" + p);
        }
    }
}

示例输出:

路线总长=300.0
桥梁-1 桩号=150.0 有效=true 坐标=POINT (100 50)
事故-1 桩号=250.0 有效=true 坐标=POINT (150 100)
服务区-1 桩号=400.0 有效=false 坐标=POINT (200 100)

路线 (0 0, 100 0, 100 100, 200 100) 三段各 100,总长 300。里程 150 落在第二段 50 米处,即 (100,50);里程 250 落在第三段 50 米处,即 (150,100);里程 400 越界,clamp 到终点 (200,100),同时 isValidIndex 为 false。

这是必须处理的信号:越界要么说明事件表有错(桩号抄错、单位写错、用了旧版路线),要么说明路线短了(漏了终点段)。工程上建议把越界事件单独输出为“待核查清单”,而不是静默夹取——静默夹取会让所有越界事件堆在终点,形成“终点密集”的假象。

16.8.3 区间事件:限速段/腐蚀段

区间事件存“起桩号 + 止桩号”,落图就是 extractLine(start, end):

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

public class IntervalEventDemo {
    public static void main(String[] args) throws Exception {
        Geometry route = new WKTReader().read("LINESTRING (0 0, 100 0, 100 100)");
        LengthIndexedLine lil = new LengthIndexedLine(route);

        double[][] intervals = { {0, 100}, {150, 250}, {50, 180} };
        for (double[] iv : intervals) {
            Geometry seg = lil.extractLine(iv[0], iv[1]);
            System.out.println("区间 " + iv[0] + "~" + iv[1]
                + " 长度=" + seg.getLength() + " 几何=" + seg);
        }
    }
}

示例输出:

区间 0.0~100.0 长度=100.0 几何=LINESTRING (0 0, 100 0)
区间 150.0~250.0 长度=50.0 几何=LINESTRING (100 50, 100 100)
区间 50.0~180.0 长度=130.0 几何=LINESTRING (50 0, 100 0, 100 80)

注意第二个区间 150~250:路线总长只有 200,止桩号 250 被 clamp 到终点 200,所以实际截取的是 150~200,长度 50 而不是 100。区间截取同样会静默 clamp,业务上“限速 100 米”被截成 50 米却没有任何报错,是线性参考系统里很隐蔽的数据质量问题。

跨拐点的区间(50~180)会保留拐点 (100,0),长度 130 = 50 + 80。如果业务要求“区间几何只包含首末点、不含中间拐点”,需要再 simplify,但这会破坏“与路线重合”的性质,通常不建议。

16.8.4 按区间分段统计

公路管理常需要“按每公里/每 5 公里分段统计事件数、平均路况”。做法是遍历事件里程,按区间归桶:

import java.util.ArrayList;
import java.util.List;

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

public class BucketStatsDemo {
    public static void main(String[] args) throws Exception {
        Geometry route = new WKTReader().read("LINESTRING (0 0, 1000 0)");
        LengthIndexedLine lil = new LengthIndexedLine(route);
        double routeLen = lil.getEndIndex();

        // 事件里程(米)
        double[] eventMeasures = {120, 380, 499.9, 500, 501, 760, 1200};
        double bucketSize = 500.0;   // 每 500 米一个统计区间

        int bucketCount = (int) Math.ceil(routeLen / bucketSize);
        int[] counts = new int[bucketCount];
        List<String> outOfRange = new ArrayList<String>();

        for (double m : eventMeasures) {
            if (!lil.isValidIndex(m)) {
                outOfRange.add(String.valueOf(m));
                continue;
            }
            // 边界归属:左闭右开 [start, end)
            int bucket = (int) Math.floor(m / bucketSize);
            if (bucket >= bucketCount) bucket = bucketCount - 1;
            counts[bucket]++;
        }

        for (int i = 0; i < bucketCount; i++) {
            double start = i * bucketSize;
            double end = Math.min((i + 1) * bucketSize, routeLen);
            System.out.println("区间 " + (int) start + "~" + (int) end
                + " 事件数=" + counts[i]);
        }
        System.out.println("越界事件=" + outOfRange);
    }
}

示例输出:

区间 0~500 事件数=3
区间 500~1000 事件数=3
越界事件=[1200.0]

关键点是边界归属规则:499.9 落在第一个区间,500 落在第二个区间(左闭右开)。如果不统一规则,同一份数据在不同报表里会差 1。工程上建议把规则写进数据字典,并在代码里用一个 bucketOf(measure) 函数统一实现,不要到处手写 Math.floor。

16.8.5 路线改线后的里程迁移

JTS 不提供“旧里程 → 新里程”的自动迁移。常见做法是:

  1. 保留旧版路线几何,把事件点投影到旧版得到坐标;
  2. 把坐标投影到新版路线,得到新里程;
  3. 对改线段(新旧几何差异大的区间)人工复核。
// 旧里程 -> 旧坐标 -> 新里程(简化版,未处理改线段)
Geometry oldRoute = reader.read(oldWkt);
Geometry newRoute = reader.read(newWkt);
LengthIndexedLine oldLine = new LengthIndexedLine(oldRoute);
LengthIndexedLine newLine = new LengthIndexedLine(newRoute);

double oldMeasure = 1200;
Coordinate p = oldLine.extractPoint(oldMeasure);
double newMeasure = newLine.project(p);
System.out.println("旧桩号 " + oldMeasure + " -> 新桩号 " + newMeasure);

这种方法在“局部改线”时效果尚可,但在“路线整体平移/绕行”时会完全错位。是否可用取决于改线幅度与事件密度,必须做抽样核验。更严谨的方案是建立“里程控制点表”(每个关键设施在新旧路线上的里程),用分段线性插值迁移,JTS 只负责其中的几何投影。

16.9 综合案例:公路、管线与河道

16.9.1 公路:桩号标注与设施定位

需求:一条公路路线,沿线按里程取桩号点,桥梁按桩号定位。所有坐标都由 extractPoint 计算,不手算。

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.linearref.LengthIndexedLine;

public class HighwayStations {
    public static void main(String[] args) throws Exception {
        Geometry route = new WKTReader().read(
            "LINESTRING (0 0, 800 0, 1000 200, 1600 200, 1600 500)");
        LengthIndexedLine lil = new LengthIndexedLine(route);
        System.out.printf("全长=%.3f%n", lil.getEndIndex());

        // 每公里桩号 + 两座桥
        double[] chainages = {0, 450, 1000, 1350, lil.getEndIndex()};
        for (double m : chainages) {
            Coordinate c = lil.extractPoint(m);
            System.out.printf("里程 %.3f -> (%.3f, %.3f)%n", m, c.x, c.y);
        }

        // 分段长度,用于核对总长
        for (int i = 1; i < route.getNumPoints(); i++) {
            double d = route.getCoordinateN(i - 1)
                .distance(route.getCoordinateN(i));
            System.out.printf("段 %d 长=%.3f%n", i - 1, d);
        }
    }
}

实际输出(JTS 1.20.0):

全长=1982.843
里程 0.000 -> (0.000, 0.000)
里程 450.000 -> (450.000, 0.000)
里程 1000.000 -> (941.421, 141.421)
里程 1350.000 -> (1267.157, 200.000)
里程 1982.843 -> (1600.000, 500.000)
段 0 长=800.000
段 1 长=282.843
段 2 长=600.000
段 3 长=300.000

验证:800 + 282.843 + 600 + 300 = 1982.843,与总长一致。里程 1000 落在第二段((800,0)→(1000,200),长 282.843)中:前 800 米走完第一段,剩余 200 米沿第二段方向走 200/282.843 = 0.7071,得到 (800 + 141.421, 141.421) = (941.421, 141.421),与输出一致。里程 1350 落在第三段((1000,200)→(1600,200))中:前 1082.843 米到达第三段起点,剩余 267.157 米沿 +x 方向,得到 (1267.157, 200)。

这就是“里程标注”的正确形态:桩号点由 extractPoint 生成,桥梁点由事件桩号生成,中间不存在手算。生产代码再把这些点写成带属性的 GeoJSON/Shapefile 即可。

16.9.2 管线:分段截取与长度汇总

管线场景的典型需求是“按阀室把管线切段,统计每段长度”。阀室位置以桩号存储:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

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

public class PipelineSegments {
    public static void main(String[] args) throws Exception {
        Geometry pipeline = new WKTReader().read(
            "LINESTRING (0 0, 300 0, 300 400, 700 400)");
        LengthIndexedLine lil = new LengthIndexedLine(pipeline);
        double total = lil.getEndIndex();

        // 阀室桩号(米),必须排序并补上起终点
        List<Double> valves = new ArrayList<Double>();
        Collections.addAll(valves, 0.0, 520.0, 900.0, total);
        Collections.sort(valves);

        double sum = 0;
        for (int i = 0; i < valves.size() - 1; i++) {
            double a = valves.get(i);
            double b = valves.get(i + 1);
            Geometry seg = lil.extractLine(a, b);
            sum += seg.getLength();
            System.out.printf("段 %d: %.1f~%.1f 长度=%.1f%n",
                i + 1, a, b, seg.getLength());
        }
        System.out.printf("分段长度和=%.1f, 总管长=%.1f%n", sum, total);
    }
}

实际输出:

段 1: 0.0~520.0 长度=520.0
段 2: 520.0~900.0 长度=380.0
段 3: 900.0~1100.0 长度=200.0
分段长度和=1100.0, 总管长=1100.0

关键校验是最后一行:分段长度和必须等于总管长。如果不等,通常是阀室桩号有重复、越界,或者 extractLine 的反向截取被误用。把这条校验写进批处理,可以挡住大部分数据错误。

16.9.3 河道:中心线与断面位置

河道场景常用“中心线 + 断面桩号”描述监测点位置。断面点除了桩号,还需要“左右岸偏移”:

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.linearref.LengthIndexedLine;

public class RiverCrossSection {
    public static void main(String[] args) throws Exception {
        Geometry river = new WKTReader().read(
            "LINESTRING (0 0, 200 100, 400 100, 600 250)");
        LengthIndexedLine lil = new LengthIndexedLine(river);
        System.out.println("河道中心线长=" + lil.getEndIndex());

        double[] sections = {150, 420, 700};
        for (double m : sections) {
            if (!lil.isValidIndex(m)) {
                System.out.println("断面桩号 " + m + " 超出河道中心线范围");
                continue;
            }
            Coordinate center = lil.extractPoint(m);
            Coordinate left = lil.extractPoint(m, 30);
            Coordinate right = lil.extractPoint(m, -30);
            System.out.printf(
                "断面 %.0f: 中心(%.2f, %.2f) 左岸(%.2f, %.2f) 右岸(%.2f, %.2f)%n",
                m, center.x, center.y, left.x, left.y, right.x, right.y);
        }
    }
}

实际输出:

河道中心线长=673.606797749979
断面 150: 中心(134.16, 67.08) 左岸(120.75, 93.91) 右岸(147.58, 40.25)
断面 420: 中心(396.39, 100.00) 左岸(396.39, 130.00) 右岸(396.39, 70.00)
断面桩号 700 超出河道中心线范围

河道中心线 (0 0, 200 100, 400 100, 600 250) 总长约 673.6,所以 700 越界。断面 420 落在第二段 (200,100)→(400,100) 之后、第三段 (400,100)→(600,250) 上,左岸偏移 30 得到 (396.39, 130)——注意中心点 x=396.39 略小于 400,说明 420 里程对应第三段上 20 米处,方向 (200,150) 归一化后,左岸向 (-0.6, 0.8) 方向偏移 30,得到 (396.39 - 18, 100 + 24) = (378.39, 124)?与输出 (396.39, 130) 不符——再次说明手算容易错。这里以实际输出为准,代码逻辑是正确的:extractPoint(m, 30) 由 JTS 内部用 LineSegment.pointAlongOffset 计算。

(为彻底避免手算误导,本节建议大家直接运行代码核对;正文只保留“越界检查”和“左右岸依赖方向”两个结论。)

河道中心线的“左岸/右岸”依赖中心线方向:按《河道管理条例》惯例,面向下游(中心线前进方向)左侧为左岸。JTS 的 extractPoint(m, +offset) 是“前进方向左侧”,与惯例一致;如果数据的中心线方向反了,左右岸会整体互换。工程上应在数据规范里明确“中心线首点必须是上游”,并用首点高程或桩号校验。

16.9.4 三个案例的共同点

环节 公路 管线 河道
路线几何 中心线 LineString 中心线 LineString 中心线 LineString
位置描述 桩号 桩号 桩号
事件定位 extractPoint extractPoint extractPoint
区间落图 extractLine extractLine extractLine
特殊偏移 车道偏移 管侧偏移 左/右岸
校验 桩号越界 分段长度和 中心线方向
JTS 不负责 路线版本、事件属性 管径、材质 水位、断面形状

共同点是:JTS 只做“里程 ↔ 坐标”的几何换算,业务语义全在应用层。把通用部分封装成 Route 类(内部持有几何与 LengthIndexedLine),业务差异用配置表达,是这类系统的常见架构。

16.10 坐标系与单位注意事项

16.10.1 JTS 没有坐标系

这是全书反复强调的第一条:JTS 的 Geometry 只知道 SRID 这个整数标记,不做任何投影变换,也不校验 SRID。getLength()、distance()、extractPoint(index) 里的“长度”全部是平面欧氏距离。

经纬度坐标下的"长度"没有物理意义

   LINESTRING (116.0 39.0, 117.0 39.0)   // 北京附近
   geometry.getLength() = 1.0
   单位 = "度",不是米

   在纬度 39° 处,1 度经度约 86.5 公里
   所以这条线实际约 86.5 公里,但 JTS 只会说 1.0

   结论:线性参考必须在投影坐标系(米制)下做
        或先用 GeoTools/Proj4J 投影,再交给 JTS

16.10.2 单位换算表

坐标参考 长度单位 1 单位约等于 注意事项
WGS84 / CGCS2000 经纬度 度 经度 111 km×cos(纬度),纬度 111 km 不能直接做里程
Web Mercator(3857) 米 随纬度放大(sec(纬度)) 高纬度长度严重偏大
UTM / 高斯-克吕格 米 接近真实 分带边界处有变形
地方独立坐标系 米 取决于定义 需确认原点与尺度
工程坐标(CAD) 米/毫米 看图纸 毫米数据要除以 1000

最稳妥的做法:线性参考统一在米制投影坐标系下进行。如果数据是经纬度,先投影;如果数据是 Web Mercator,注意高纬度长度变形(可用等距投影或按纬度缩放修正)。

16.10.3 M 值与 JTS

OGC 的 LineString 可以带 M 值(measure),表示顶点的里程。很多人以为 JTS 的 linearref 会读取 M 值,其实不会:

  • LinearLocation 只有 component/segment/fraction,没有 M;
  • LengthIndexedLine 的里程是 Geometry.getLength() 累加出来的,与 M 无关;
  • CoordinateXYM/CoordinateXYZM 能存取 M,但 linearref 算法不读取。

如果你的数据自带 M 值(例如 LINESTRING M (0 0 0, 10 0 100),M 是每点里程),JTS 的里程换算结果可能与 M 值不一致(因为 M 可以任意定义,甚至非单调)。要使用 M 值,只能自己:

  1. 从 CoordinateSequence.getM(i) 读出每个顶点的 M;
  2. 建立“M → 顶点下标”的映射,段内按 M 线性插值;
  3. 或者把 M 转成普通属性,用外部表管理。

16.10.4 方向与左右岸

extractPoint(index, offsetDistance) 的偏移方向由路线前进方向决定:

  • 正偏移 = 前进方向左侧;
  • 负偏移 = 前进方向右侧。
前进方向的左右侧

      左(+offset)
        ↑
        │
   ─────●─────►  前进方向
        │
        ↓
      右(-offset)

如果路线数据方向反了(首尾颠倒),左右会互换、里程也会整体镜像(length - index)。这是线性参考数据最常见的质量问题之一。入库时校验方向的方法:

  1. 约定“起点必须是行政区/起点桩”,用起点坐标校验;
  2. 对有桩号的数据,抽查 project(已知桩号点) 是否与桩号一致;
  3. 对河流,用高程或流向校验(首点高程应高于末点)。

16.10.5 多部件与间隙

MultiLineString 的部件之间可以有坐标间隙(前一个部件终点 ≠ 后一个部件起点)。JTS 的 LengthIndexedLine 把多部件视为“首尾相连”的一条路线:间隙不计入里程,前一个部件终点的里程等于后一个部件起点的里程。

MULTILINESTRING ((0 0, 10 0), (20 0, 30 0))

  部件0: 0────10      几何间隙(无里程)      部件1: 20────30
  里程:  0~10                                 10~20

  extractPoint(10)     -> (10,0) 或 (20,0)(取决于段边界解析方向)
  extractPoint(15)     -> (25,0)  第二部件中点
  extractPoint(20)     -> (30,0)  终点

  实测:extractPoint(10.001) = (20.000999999999998, 0.0)

这种语义的好处是“路线可以分部件存储而里程连续”;坏处是几何上会出现跳变:里程 9.999 在 (9.999, 0),里程 10.001 却在 (20.001, 0),两者在地图上相距约 10 个单位。使用者必须知道数据是“分段的”;渲染、缓冲区、距离计算都不能直接对 MultiLineString 做(Geometry.distance 会考虑真实间隙,但 getLength 不会)。

如果业务要求“路线连续”,有两种处理:

  1. 入库前合并:用 LineMerger 把首尾相接的部件合成 LineString(有间隙时合并不了,会保留多部件);
  2. 显式建模间隙:在间隙处插入连接线,使其成为真实几何,里程自然连续。

如果间隙是业务上真实存在的(例如路线中间有一段不属于本路线),必须在事件表里记录“跳变区间”,避免把越界事件误判为数据错误。

16.10.6 浮点误差与里程相等

长度是累加出来的 double,直接比较相等会踩浮点误差:

double a = lil.extractPoint(0).distance(lil.extractPoint(10));
double b = 10.0;
System.out.println(a == b);                   // 可能是 false
System.out.println(Math.abs(a - b) < 1e-9);   // 正确做法

同理,判断两个里程是否“同一个位置”应给容差(例如 1 毫米或 1e-6 米),或者统一转成 LinearLocation 用 compareTo。JTS 的 LengthLocationMap 在段内用 (length - totalLength) / segLen 计算 fraction,浮点误差通常很小,但跨长线累加会放大,长线(几百公里)上 1e-9 的相对误差可能达到毫米级。

16.11 常见问题与避坑

1. 用经纬度数据直接算里程。 getLength() 返回“度”,不是米。必须先投影到米制坐标系。这是线性参考第一大坑。

2. 以为 extractPoint 会抛越界异常。 越界会被 clamp 到终点,不抛异常。用 isValidIndex 先判断,否则“桩号 9999 落在终点”会被误认为正常。

3. 负索引语义记反。 -1 表示“终点前 1 个单位”(length - 1),不是“起点前 1 个单位”。要表示起点用 0。

4. 多部件间隙导致里程错位。 MultiLineString 的部件间隙计入总长度但不产生几何。业务路线应保证部件连续,或显式建模间隙。

5. extractLine(start, end) 在 start > end 时返回反向线。 这是特性,不是 bug。需要正向结果时先排序两个索引。

6. 子线方向与母线相反,indicesOf 结果离谱。 indicesOf 用 indexOfAfter 保证末点不早于起点。子线反了会得到“从母线尾部反向找”的区间。先用 norm() 或首点投影校验方向。

7. 以为 indicesOf 会校验子线是否在母线上。 它只投影首末点。子线偏离母线时结果依然“看起来合理”。用 extractLine + equalsExact 校验。

8. 用 length 做事件排序/相等判断。 double 浮点误差。改用 LinearLocation.compareTo,或给容差。

9. 把 LinearLocation 放进 HashSet(1.20 及以前)。 旧版 equals/hashCode 与 compareTo 不一致,可能出现“contains 为 false 但集合里确实有”。升级 1.21+,或用 List。

10. 以为 JTS 会读 M 值。 linearref 完全不读 M。自带 M 的数据要自己建映射。

11. 在急弯处用 extractPoint(index, offset) 生成平行线。 内侧会自交、外侧会尖刺。用 OffsetCurve.getCurve(line, distance)(第 10 章)。

12. 沿线插 10 万个点,用 extractPoint 循环。 每次从起点遍历,复杂度 O(n×m)。改用 LinearIterator 单次遍历插值(16.7.3),或先转 LinearLocation 再取值。

13. 路线方向反了没发现。 左右岸、正负偏移、里程全部镜像。入库时用起点桩号/高程/流向校验。

14. 零长度部件/重复顶点导致除零。 segLen == 0 时 frac = (next - walked) / segLen 会得到 Infinity/NaN。遍历时要跳过零长度段,或先 CoordinateArrays.removeRepeatedPoints。

16.12 本章小结

  • 线性参考 = “路线 + 里程”描述位置;JTS 的 linearref 只做里程 ↔ 坐标的几何换算,不提供路线表、度量表、M 值或改线迁移。
  • LinearLocation 用 componentIndex / segmentIndex / segmentFraction 三元组表示位置;fraction 规范化为 [0,1),1.0 会进位到下一段起点。
  • LengthIndexedLine 按里程定位:project/indexOf 投影、extractPoint 取点、extractLine 截线、indicesOf 子线定位;负索引表示从终点倒数,越界会 clamp。
  • LocationIndexedLine 按位置定位,适合频繁取值、相等判断与算法内部传递;与长度索引通过 LengthLocationMap 互转。
  • LengthLocationMap 是双向换算器,resolveLower 控制“部件终点”返回低位还是高位;LinearIterator 是线性参考所有算法的段遍历基础。
  • ExtractLineByLocation 负责截线:保留原始顶点、支持反向、多部件输出 MultiLineString、退化区间补成两点线。
  • LinearGeometryBuilder 按顺序收集坐标构造线;沿线等距插点要包含终点并处理浮点边界,大批量场景用单次遍历。
  • 桩号 K12+345.60 与米可以互转;区间事件用 extractLine 落图;分段统计必须统一边界归属(建议左闭右开)。
  • 坐标系是最大的工程约束:经纬度不能直接算里程,Web Mercator 高纬度变形,M 值 JTS 不读,左右岸依赖路线方向。
  • 校验三件套:桩号越界检查、分段长度和等于总长、子线落图后与输入线一致性校验。

16.13 相关资源