/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
界址点不是"把多边形顶点导出来"那么简单:一次 JTS 几何生成器的设计与踩坑
需求最初听起来特别朴素:数据库里存着每块地的多边形,把顶点导出来,编号,入库,完事。
真正做起来才发现,光是"相邻两块地共享的那个角点"就能折腾好几天——两块地各自生成了一份,坐标差了 4 厘米,编号对不上;用几何简化把采集产生的微锯齿删掉之后,共享边端点被删了,两块地的边界从此不再吻合;挂在该点上的"所属地块编码",一会儿多了隔壁那块隔着 5 厘米缝隙、根本没挨着的地,一会儿又漏了真正共享的第三块。
这篇把这套界址点生成器的设计完整拆一遍。代码全部脱敏,业务表名、字段、类名和坐标都换成了通用命名,但算法和参数与线上一致。
1. 界址点到底是什么
在地籍业务里,界址点是地块界线的转折点,是确权登记里具有法定意义的点位,输出时要带:
- 点号:一个县范围内(或某个批次内)唯一、连续、可复现;
- 顺序:沿地块边界顺时针,从固定起点开始,同一块地任何时候生成结果都一样;
- 共享性:两块地的公共角点只能有一个界址点,且这个点要同时挂上两块地的编码;
- 坐标:精确的 X/Y、空间几何、以及目标坐标系下的位置。
而原始数据是什么样呢?外业采集的多边形,一条本来笔直的田埂可能被打成十几个点,相邻点间距十几厘米,一个中等县能有几十万甚至上百万个顶点。直接 ST_DumpPoints 全导出来,既不符合"界址点是转折点"的定义,也会把库撑爆。
所以这件事的本质是:在保留真实转折与共享关系的前提下,对多边形环做确定性抽稀,并把跨地块的同位点归并成一个实体。
2. 第一版:一套 PostGIS 存储过程
最早的实现是一段 SQL,思路相当直接:
— 1. 自定义函数:外环强制顺时针,并从"西北角最近点"开始重排
CREATE OR REPLACE FUNCTION fn_sorted_ring(geom_input GEOMETRY) RETURNS GEOMETRY AS $$
— ST_ForceRHR → ST_DumpPoints → 按到 (ST_XMin, ST_YMax) 的距离找起点
— → 环状重排 → ST_MakePolygon(闭合线)
$$ LANGUAGE SQL IMMUTABLE STRICT;
— 2. 展开所有顶点,带全局序号
CREATE TEMP TABLE spread_points AS
SELECT parcel_code,
ST_YMax(geom) AS parcel_y_max,
ST_XMin(geom) AS parcel_x_min,
(pt).geom AS point_geom,
ROW_NUMBER() OVER (
ORDER BY ST_YMax(geom) DESC, ST_XMin(geom), parcel_code, (pt).path[1]
) AS sort_idx
FROM parcel_table dk,
LATERAL ST_DumpPoints(
ST_ExteriorRing(fn_sorted_ring(ST_SimplifyPreserveTopology(dk.geom, 0.0001)))
) AS pt
WHERE (pt).path[1] > 1 AND ST_IsValid(dk.geom);
CREATE INDEX ON spread_points USING GIST (point_geom);
— 3. 按坐标分组,合并共享点
CREATE TEMP TABLE unique_points AS
SELECT point_geom,
STRING_AGG(parcel_code, '/' ORDER BY sort_idx) AS merged_parcel_code,
(ARRAY_AGG(sort_idx ORDER BY sort_idx))[1] AS sort_idx
FROM spread_points
GROUP BY point_geom
ORDER BY sort_idx;
— 4. 编号 + 坐标变换后落库
INSERT INTO boundary_point_staging (seq_no, point_no, parcel_code, x, y, geom)
SELECT ROW_NUMBER() OVER (ORDER BY sort_idx),
ROW_NUMBER() OVER (ORDER BY sort_idx) + :start_no,
merged_parcel_code,
ST_X(point_geom)::TEXT, ST_Y(point_geom)::TEXT,
ST_Transform(point_geom, 4490)
FROM unique_points;
跑起来没问题,但越用越别扭:
| 临时表绑会话 | TEMP TABLE 的生命周期挂在数据库连接上,必须靠事务共享同一个 Connection,调度顺序一旦错了就找不到表;跨库、跨连接池基本不可移植 |
| 合并靠精确相等 | GROUP BY point_geom 是二进制级 2D 相等。外业采集的两块地共享角点差个几厘米,就合并不了,各出一个点 |
| 简化会删掉共享端点 | ST_SimplifyPreserveTopology 只保证单个地块自身拓扑正确,不感知地块间的共享边。A 地保留了那个点、B 地删了,共享边就错位了 |
| 没有跨地块判定 | 哪些点是"共享边端点"、哪些是"三面共点",SQL 版本完全没有概念,只能靠事后 GROUP BY 碰运气 |
| 不可单测 | 算法全在存储过程里,想验证一个边界 case 得连数据库、建临时表、跑一遍全套,CI 里根本没法做 |
于是决定把它搬进 Java,用 JTS 在内存里重写一遍。顺便把上面这些历史欠账一起还了。
3. 整体流水线
#mermaid-svg-GWWGmYTCFdV2Isi4{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GWWGmYTCFdV2Isi4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GWWGmYTCFdV2Isi4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GWWGmYTCFdV2Isi4 .error-icon{fill:#552222;}#mermaid-svg-GWWGmYTCFdV2Isi4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GWWGmYTCFdV2Isi4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GWWGmYTCFdV2Isi4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GWWGmYTCFdV2Isi4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GWWGmYTCFdV2Isi4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GWWGmYTCFdV2Isi4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GWWGmYTCFdV2Isi4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GWWGmYTCFdV2Isi4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GWWGmYTCFdV2Isi4 .marker.cross{stroke:#333333;}#mermaid-svg-GWWGmYTCFdV2Isi4 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GWWGmYTCFdV2Isi4 p{margin:0;}#mermaid-svg-GWWGmYTCFdV2Isi4 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-GWWGmYTCFdV2Isi4 .cluster-label text{fill:#333;}#mermaid-svg-GWWGmYTCFdV2Isi4 .cluster-label span{color:#333;}#mermaid-svg-GWWGmYTCFdV2Isi4 .cluster-label span p{background-color:transparent;}#mermaid-svg-GWWGmYTCFdV2Isi4 .label text,#mermaid-svg-GWWGmYTCFdV2Isi4 span{fill:#333;color:#333;}#mermaid-svg-GWWGmYTCFdV2Isi4 .node rect,#mermaid-svg-GWWGmYTCFdV2Isi4 .node circle,#mermaid-svg-GWWGmYTCFdV2Isi4 .node ellipse,#mermaid-svg-GWWGmYTCFdV2Isi4 .node polygon,#mermaid-svg-GWWGmYTCFdV2Isi4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-GWWGmYTCFdV2Isi4 .rough-node .label text,#mermaid-svg-GWWGmYTCFdV2Isi4 .node .label text,#mermaid-svg-GWWGmYTCFdV2Isi4 .image-shape .label,#mermaid-svg-GWWGmYTCFdV2Isi4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-GWWGmYTCFdV2Isi4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-GWWGmYTCFdV2Isi4 .rough-node .label,#mermaid-svg-GWWGmYTCFdV2Isi4 .node .label,#mermaid-svg-GWWGmYTCFdV2Isi4 .image-shape .label,#mermaid-svg-GWWGmYTCFdV2Isi4 .icon-shape .label{text-align:center;}#mermaid-svg-GWWGmYTCFdV2Isi4 .node.clickable{cursor:pointer;}#mermaid-svg-GWWGmYTCFdV2Isi4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-GWWGmYTCFdV2Isi4 .arrowheadPath{fill:#333333;}#mermaid-svg-GWWGmYTCFdV2Isi4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-GWWGmYTCFdV2Isi4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-GWWGmYTCFdV2Isi4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GWWGmYTCFdV2Isi4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-GWWGmYTCFdV2Isi4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GWWGmYTCFdV2Isi4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-GWWGmYTCFdV2Isi4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-GWWGmYTCFdV2Isi4 .cluster text{fill:#333;}#mermaid-svg-GWWGmYTCFdV2Isi4 .cluster span{color:#333;}#mermaid-svg-GWWGmYTCFdV2Isi4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-GWWGmYTCFdV2Isi4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-GWWGmYTCFdV2Isi4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-GWWGmYTCFdV2Isi4 .icon-shape,#mermaid-svg-GWWGmYTCFdV2Isi4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GWWGmYTCFdV2Isi4 .icon-shape p,#mermaid-svg-GWWGmYTCFdV2Isi4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-GWWGmYTCFdV2Isi4 .icon-shape .label rect,#mermaid-svg-GWWGmYTCFdV2Isi4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GWWGmYTCFdV2Isi4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-GWWGmYTCFdV2Isi4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-GWWGmYTCFdV2Isi4 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
输入: 地块编码 → 多边形几何
构建原始边界线段 STRtree 索引
逐地块: 外环顺时针 + 西北角起点重排
在原始环上判定关键点归属交集法
拓扑保持简化删除微锯齿
关键点补回简化环restoreKeyPoints
夹角过滤循环链表增量重算
全局排序 确定序号
Quadtree 容差合并共享点锚点择优
用原始边界补齐相邻地块编码
坐标变换 + 生成记录
有个贯穿全程的原则后面会反复出现:关键点判定必须在「原始环」上做,简化之后再把它们补回来。顺序反了,共享边端点就找不回来了。
4. 确定性编号:为什么从西北角开始
界址点号要可复现,就得先固定"从哪个点开始、往哪个方向走"。
JTS 里多边形外环默认是逆时针(CCW),而业务要求顺时针。所以先判方向、必要时反转,再找离包围盒西北角 (XMin, YMax) 最近的顶点作为起点,环状重排后闭合:
public static Polygon reorderRingStart(Geometry parcel) {
Polygon polygon = (Polygon) parcel.getGeometryN(0);
Coordinate[] coords = polygon.getExteriorRing().getCoordinates();
// 外环统一顺时针
Coordinate[] clockwise = Orientation.isCCW(coords) ? reverse(coords) : coords;
double nwX = parcel.getEnvelopeInternal().getMinX();
double nwY = parcel.getEnvelopeInternal().getMaxY();
int uniqueCount = clockwise.length – 1; // 去掉闭合点
int startIndex = 0;
double bestDistance = Double.MAX_VALUE;
for (int i = 0; i < uniqueCount; i++) {
double dx = clockwise[i].x – nwX;
double dy = clockwise[i].y – nwY;
double distance = dx * dx + dy * dy;
if (distance < bestDistance) {
bestDistance = distance;
startIndex = i;
}
}
Coordinate[] reordered = new Coordinate[uniqueCount];
for (int i = 0; i < uniqueCount; i++) {
reordered[i] = clockwise[(startIndex + i) % uniqueCount];
}
Coordinate[] closed = new Coordinate[uniqueCount + 1];
System.arraycopy(reordered, 0, closed, 0, uniqueCount);
closed[uniqueCount] = reordered[0].copy();
return polygon.getFactory().createPolygon(closed);
}
选西北角不是玄学,是给"从北到南、从西到东"的全县排序定一个稳定锚点。后续全局排序也沿用同一套规则:
spreadPoints.sort(Comparator
.comparingDouble((SpreadPoint p) -> –p.parcelYMax) // 北优先
.thenComparingDouble(p -> p.parcelXMin) // 再西优先
.thenComparing(p -> p.parcelCode)
.thenComparingInt(p -> p.pointIndexInRing));
一个容易漏的细节:环是闭合的,coords[0] 和 coords[last] 是同一个点。取点时如果简单跳过下标 0、遍历到 length-2,恰好落在西北角的那个关键点就永远丢了。正确做法是从 1 遍历到 length-1(含闭合点),用闭合点代替起点,并且它的关键点标记复用 key[0]:
for (int i = 1; i < coords.length; i++) {
boolean isKeyPoint = (i < keyFlags.length) ? keyFlags[i] : keyFlags[0];
// … 生成点
}
这个 bug 很隐蔽——只有当"三面共点恰好是西北角最近点"时才会触发,测试地块不覆盖到就发现不了。
5. 最核心的一步:关键点判定(归属交集法)
这是整个重写里最值钱的部分,也是踩坑最多的地方。
目标:识别出"共享边端点""三面共点"这类分叉点,它们必须无条件保留,不能简化、不能被夹角过滤删掉。
5.1 失败过的思路
最早试过两种:
- 基于端点归属编码求并集:先把每个顶点挂上它"看起来属于"哪些地块,再把相邻点的编码并起来。问题是并集会把整条共享边上的所有点都算成"共享",无法区分边的端点和边的内部点,在逐地块独立生成的模式下彻底失效。
- 判断"段中点落在哪些边界上":每环要做 n 次点查询 + n 次段中点查询,而且不对称——同一共享点在 A 的环上判出来了、在 B 的环上可能漏判,导致两块地的边界不一致。
5.2 最终方案
先把所有地块的原始边界线段(外环 + 内环)塞进一个 STRtree,每条线段记着自己属于哪个地块,包围盒按容差外扩以便查询命中:
private static STRtree buildSegmentIndex(Map<String, Geometry> parcels) {
STRtree index = new STRtree();
for (Map.Entry<String, Geometry> entry : parcels.entrySet()) {
Geometry geometry = entry.getValue();
for (int i = 0; i < geometry.getNumGeometries(); i++) {
Polygon polygon = (Polygon) geometry.getGeometryN(i);
addRingSegments(index, entry.getKey(), polygon.getExteriorRing(), tolerance);
for (int j = 0; j < polygon.getNumInteriorRing(); j++) {
addRingSegments(index, entry.getKey(), polygon.getInteriorRingN(j), tolerance);
}
}
}
index.build();
return index;
}
public static void addRingSegments(STRtree index, String parcelCode,
LineString ring, double tolerance) {
Coordinate[] coords = ring.getCoordinates();
for (int i = 0; i < coords.length – 1; i++) {
Envelope envelope = new Envelope(coords[i], coords[i + 1]);
envelope.expandBy(tolerance);
index.insert(envelope, new RingSegment(parcelCode, coords[i], coords[i + 1]));
}
}
然后三步出结果:
double geoTolerance = tolerance * GEOM_TOLERANCE_SCALE;
int uniqueCount = ring.length – 1;
// ① 每个顶点的归属集合 = 点落在哪些"其它地块"的原始边界上 + 自身
Set<String>[] pointParcels = new Set[uniqueCount];
for (int i = 0; i < uniqueCount; i++) {
Set<String> owners = new HashSet<>(
otherBoundaryParcelCodes(ring[i], currentParcelCode, index, geoTolerance));
owners.add(currentParcelCode);
pointParcels[i] = owners;
}
// ② 段归属 = 两端点归属集合的交集(只有共同归属的地块之间才"同行")
Set<String>[] segmentParcels = new Set[uniqueCount];
for (int i = 0; i < uniqueCount; i++) {
Set<String> owners = new HashSet<>(pointParcels[i]);
owners.retainAll(pointParcels[(i + 1) % uniqueCount]);
segmentParcels[i] = owners;
}
// ③ 前后两段归属不同 = 此处分岔 → 强制保留
for (int i = 0; i < uniqueCount; i++) {
keyPoints[i] = !segmentParcels[(i – 1 + uniqueCount) % uniqueCount]
.equals(segmentParcels[i]);
}
其中"点是否落在别的地块边界上"用 Distance.pointToSegment 做紧容差判定:
private static Set<String> otherBoundaryParcelCodes(Coordinate coord, String currentParcelCode,
STRtree index, double tolerance) {
Envelope query = new Envelope(coord.x – tolerance, coord.x + tolerance,
coord.y – tolerance, coord.y + tolerance);
Set<String> found = new HashSet<>(4);
for (RingSegment segment : (List<RingSegment>) index.query(query)) {
if (segment.parcelCode.equals(currentParcelCode)) {
continue; // 排除自身
}
if (Distance.pointToSegment(coord, segment.start, segment.end) <= tolerance) {
found.add(segment.parcelCode);
}
}
return found;
}
为什么是"交集"而不是"并集":一条共享边的两个端点,各自归属 {A, B},交集仍是 {A, B};边内部点同样如此 → 前后段归属相同 → 判为"共享边内部点",不强制保留。而共享边的端点之后那一段拐进了 A 自己的地界,端点归属就收缩成 {A} 了,前后段交集不同 → 判为分叉点。三面共点、一侧退出共享,都是同一套逻辑自然覆盖。
这套方法相比旧思路有三个好处:对称(同一共享点在所有共享地块的环上都会被判为关键点)、更快(每环 n 次几何查询,而不是 2n 次)、自洽(不与其他地块相交的点,归属恒为 {自身},前后段交集自然相同,无需提前跳过)。
6. 简化的顺序陷阱:先判 → 再简化 → 补回
TopologyPreservingSimplifier 用来删微锯齿很趁手,但它有个致命前提:只保证单个地块自身的拓扑正确,完全不感知地块间的共享边。
private static Geometry simplifyParcel(Geometry parcel, Double tolerance) {
if (tolerance == null) {
return parcel;
}
try {
Geometry simplified = TopologyPreservingSimplifier.simplify(parcel, tolerance);
if ((simplified instanceof Polygon || simplified instanceof MultiPolygon)
&& simplified.isValid()
&& ((Polygon) simplified.getGeometryN(0)).getExteriorRing().getNumPoints() >= 4) {
return simplified;
}
} catch (Exception e) {
log.warn("几何简化失败,回退原始几何", e);
}
return parcel; // 退化时回退,不能硬用
}
所以顺序必须是:在原始环上判关键点 → 简化 → 把关键点补回简化环 → 夹角过滤时关键点强制保留。
restoreKeyPoints 干两件事:简化环里还留着的关键点,按紧容差坐标匹配打标记;被简化删掉的关键点,计算它在简化环哪条边上的投影、插回那条边中间。
// 只遍历"原始关键点下标",避免 O(n×m) 全量扫描
for (int i = 0; i < simplifiedCount; i++) {
for (int t = 0; t < keyIndexCount; t++) {
if (list.get(i).distance(originalRing[keyIndices[t]]) <= keyMatchTolerance) {
flags.set(i, true);
break;
}
}
}
// 被删掉的关键点:找投影落在段内且距离最近的简化边,插进去
double t = ((k.x – a.x) * dx + (k.y – a.y) * dy) / lengthSquared;
if (t >= 0 && t <= 1) {
double projDistance = Math.hypot(k.x – (a.x + t * dx), k.y – (a.y + t * dy));
// 取最近的边插入,并同步插入 key / owner 标记
}
补回之后,共享边端点在所有相邻地块的环上都存在了,边界才能重新对齐。
7. 夹角过滤:循环链表 + 增量重算
剩下的非关键点按夹角过滤:接近共线(夹角过大)的冗余点删掉,明显转折的拐点保留。
难点在于删掉一个点之后,它前后两个邻居的夹角变了,得重算。如果每删一个就全环扫一遍,复杂度很难看。做法是拿 prev / next 数组模拟循环链表,加一个队列做增量重算——删点只把两个邻居重新入队:
int[] prev = new int[uniqueCount];
int[] next = new int[uniqueCount];
for (int i = 0; i < uniqueCount; i++) {
prev[i] = (i – 1 + uniqueCount) % uniqueCount;
next[i] = (i + 1) % uniqueCount;
}
ArrayDeque<Integer> queue = new ArrayDeque<>(uniqueCount);
// … 全部入队
double maxCos = Math.cos(Math.toRadians(maxAngle));
while (!queue.isEmpty()) {
int i = queue.poll();
if (!alive[i] || remaining <= 3) {
continue; // 至少保留三角形
}
int a = prev[i], c = next[i];
// cos θ = (BA·BC) / (|BA||BC|)
double cos = dot(v1, v2) / (len1 * len2);
boolean nearlyCollinear = cos < maxCos;
if (nearlyCollinear && !isKeyPoint[i]) { // 关键点强制保留
alive[i] = false;
remaining—;
next[a] = c;
prev[c] = a;
enqueueIfNeeded(a); // 只重算两个邻居
enqueueIfNeeded(c);
}
}
用 cos 比较而不是 Math.acos,省掉每点一次反三角函数;queued[] 防止同一节点重复入队。这个结构在几十万顶点的批次上差别很明显。
8. 共享点合并:Quadtree、双重容差与锚点择优
8.1 别拿 Coordinate 当 Map key
第一版用过 Map<Coordinate, …> 去重,结果踩了坑。Coordinate 的 x / y 是 public 可变字段,equals/hashCode 契约还依赖具体实现(2D 比较、是否含 z 值在不同版本/子类里并不一致),拿它当哈希键就是在赌第三方类的实现细节。
不如自己定义一个精确的 2D key,用 Double.doubleToLongBits 把浮点位型固定下来:
private static final class PointKey {
final long xBits;
final long yBits;
PointKey(double x, double y) {
this.xBits = Double.doubleToLongBits(x);
this.yBits = Double.doubleToLongBits(y);
}
@Override
public boolean equals(Object other) {
if (!(other instanceof PointKey)) {
return false;
}
PointKey key = (PointKey) other;
return xBits == key.xBits && yBits == key.yBits;
}
@Override
public int hashCode() {
return Objects.hash(xBits, yBits);
}
}
顺带把 -0.0 之类的浮点边角情况也统一了。
8.2 容差合并,而不是精确相等
采集误差决定了共享点坐标不可能完全一样。用 Quadtree 在容差邻域里找最近点合并,用 LinkedHashSet 保持地块编码首次插入顺序、天然去重:
Quadtree index = new Quadtree();
for (SpreadPoint point : spreadPoints) {
Envelope search = new Envelope(point.coord.x – tolerance, point.coord.x + tolerance,
point.coord.y – tolerance, point.coord.y + tolerance);
UniquePoint nearest = null;
double best = tolerance;
for (UniquePoint candidate : (List<UniquePoint>) index.query(search)) {
double distance = candidate.coord.distance(point.coord);
if (distance <= best) {
best = distance;
nearest = candidate;
}
}
if (nearest == null) {
UniquePoint created = new UniquePoint(point.coord.copy(), sortIndex, point.parcelCode,
point.isKeyPoint, point.ownerParcels);
uniques.add(created);
index.insert(new Envelope(point.coord.x, point.coord.x,
point.coord.y, point.coord.y), created);
} else {
nearest.appendParcelCode(point.parcelCode); // "A/B/C"
}
}
8.3 双重容差:被 5 厘米坑过一次
这是整个项目里最"贵"的一个 bug。
一开始合并容差和几何判定容差用同一个值(经纬度 1e-6,约 0.11 米)。结果两块根本没挨着、中间隔着约 5.5 厘米缝隙的地,一侧顶点到另一侧边界只有 5.14 厘米,小于 11 厘米,被判成共享点,硬生生挂上了对方的地块编码。
问题在于:"两个点是否该合并"和"一个点是否真的落在别人边界上"是两个不同严格度的问题。合并要容忍采集误差,可以宽松;几何归属判定必须严,否则会把"邻近"当成"相接"。
private static final double MERGE_TOLERANCE = 1e-6; // 约 0.11 m,用于合并
private static final double GEOM_TOLERANCE_SCALE = 0.2; // 判定容差 = 合并容差 × 0.2
// geoTolerance = 2e-7 ≈ 0.022 m,用于点到线段距离、关键点匹配、相邻编码补齐
索引查询范围仍用宽松的 MERGE_TOLERANCE(保证候选线段不漏),只把最终的点到线段距离判定收紧到 geoTolerance。真正共享的界址点在两侧原始边界上坐标一致、距离约等于 0,紧容差照样能挂上。
8.4 锚点择优:谁才有资格代表这个位置
两个点合并成一个,坐标取谁?直接取"先到的那个"会出问题:先到的可能只是本地块一个冗余顶点,距真实共享点 4 厘米,反而把真正的共享端点带偏了。
规则有两条:
boolean superset = point.isKeyPoint && nearest.isKeyPoint
&& point.ownerParcels != null && nearest.anchorParcels != null
&& point.ownerParcels.size() > nearest.anchorParcels.size()
&& point.ownerParcels.containsAll(nearest.anchorParcels);
if (point.isKeyPoint && (!nearest.isKeyPoint || superset)) {
nearest.coord = point.coord.copy(); // 用更具代表性的坐标
nearest.isKeyPoint = true;
}
最后还有一步 attachAdjacentDkbm:遍历合并后的每个点,用原始边界(不是简化后的)补齐它还落在哪些地块边界上。这样即使某个共享点被夹角过滤删掉了,相邻地块编码也不会丢。
9. 两种编号模式与落库
业务上有两种诉求,用"是否传入起始编号"来分流:
| 触发条件 | 传入起始点号 | 不传起始点号 |
| 共享点 | 跨地块合并为一个,编码形如 A/B | 不合并,每块地各保留一份 |
| 点号 | 全批次连续,从起始号递增 | 每地块从 1 重新连续编号 |
| 标识码 | 1..n 全局唯一 | 全局唯一递增,避免主键冲突 |
| 适用场景 | 批次内不允许重复编号 | 每块地要有完整界址点列表 |
两种模式共用同一套关键点判定、简化、补回、夹角过滤逻辑,只在"是否合并共享点 + 怎么编号"上分叉。
落库编排上有个细节值得记一笔:先删后插,但所有计算都做完才删。整个流程是「查几何 → 按行政区分组 → 生成点 → 聚合构建点位串 → 生成界址线 → 最后才删除旧数据并批量插入」,中间任何一步抛异常,库里还是旧数据,不会半途留下一批残缺的点。分批用 Lists.partition 控制单次写入量。
坐标变换用 proj4j 做,CRS 对象按 SRID 缓存;输出文本坐标用 BigDecimal.valueOf(v).toPlainString(),避免 Double.toString 在小数上输出科学计数法,把地籍坐标写坏。
10. 边界、代价与小结
几个必须说清的前提:
- 容差必须按坐标系调。1e-6 是经纬度下的约 0.11 米;换成米制投影坐标,合理值完全不同。写成常量之前先确认数据坐标系。
- 所有几何 SRID 必须一致且非空,否则没法自动变换到目标坐标系;代码里对此是直接抛异常的,不做猜测。
- 简化容差与合并容差目前共用一个入参,但两者语义不同(一个控制几何抽稀,一个控制点位归并)。这是历史遗留,理想情况是拆成两个独立参数。
- 只处理第一个部件的外环。内环(洞)会进线段索引参与关键点判定,但不生成界址点;多部件地块只取第一个 Polygon。业务上要覆盖全部部件得另外扩。
- 配套工具:两点之间的界线截取用 LengthIndexedLine 沿环投影取最短弧,短弧跨越环起点时拆成两段输出 MULTILINESTRING;这类纯几何逻辑全部收敛成静态方法,方便单测。
回头看,从 SQL 搬到 JTS 真正换来的不是"性能",而是可控制、可测试、可解释:
| 共享点合并 | GROUP BY 精确相等 | Quadtree + 容差 + 锚点择优 |
| 共享边端点 | 简化后可能丢失 | 原始环判关键点、简化后强制补回 |
| 跨地块关系 | 无 | STRtree 归属交集法 |
| 会话依赖 | 临时表绑 Connection | 纯内存,无状态 |
| 可测试性 | 需连库跑全套 | 每个算法都是静态方法,可单测 |
三个最值得记住的点:
本文代码为脱敏后的等价实现,业务表名、字段、类名与坐标均为泛化或虚构示例;文中 JTS / PostGIS / proj4j 的 API 名称保持原样。




