feat: parameterize complex junction geometry with Gaode reference

- 高德 GeoJSON 参考流程: `scripts/lib/gaode-junction-reference.js`
  与 `scripts/inspect-junction-reference.js` 将 GCJ-02 参考转换为 WGS84,
  按 node id/最近距离关联 OSM, 支持普通路口面和 `complex-cluster` 两种匹配。
- 复合路口模板 `complex-junction-v1`: `scripts/lib/complex-junction.js` 用参考
  几何校准 core 半径, 生成路口面、进口路面、斑马线、停止线、角部圆角与安全岛;
  拓扑/信号/连接全部沿用 OSM/native。
- 车道中心线控制要素避让: `compileLaneCenterlines` 现接收模板已产出的斑马线/停止线,
  新增 `trimLaneOutsideControls` 按到路口中心的半径定向裁剪; 标线源几何同步裁剪, 不再
  越过斑马线继续画到核心区。拓扑几何不变, connector 集合前后一致。
- 复合路口人行道转角: `buildComplexJunctionGeometry` 沿已定义的路缘生成 2m 宽转角带,
  复用圆角曲线, 通过 `islands` 通道并入 `sidewalk_surface`; 自交或坐标非有限时报
  `complex-junction-sidewalk-corner-fallback` 并跳过。
- 新增诊断: `complex-junction-configured-radius-ignored`、
  `lane-centerline-fully-inside-control`、`complex-junction-sidewalk-corner-fallback`。
- 死码清理: 移除未被调用的 `clusterApproachRing`。
- spec 更新: `.trellis/spec/pipeline/cli-and-stages.md` 复合路口小节补充控制要素
  避让顺序、人行道转角契约、Validation 矩阵三行; 索引新增导航。
- 任务产物 `08-19-gaode-junction-reference`: 8 条验收标准全部实测记录,
  Scope Drift / Verification Log / Known Gaps 三节沉淀本次工作。

Regression: test:native-road / test:road-workbench / test:preflight /
test:native-preview-traffic / test:package-contract / test:traffic-signals /
test:gaode-junction-reference 全绿; road:check ok=true, errors=[]。
This commit is contained in:
2026-08-21 11:59:03 +08:00
parent 9a8dbc1a74
commit 12aeda9a63
31 changed files with 2134 additions and 95 deletions

View File

@@ -16,6 +16,11 @@ import sys
import bpy
import numpy as np
try:
from tqdm import tqdm
except ImportError:
tqdm = None
# --factory-startup does not put the script's own directory on sys.path, so the
# osmassets package next to this file is not importable without this.
_HERE = os.path.dirname(os.path.abspath(__file__))
@@ -603,9 +608,39 @@ def semantic_asset_specs(glb_path, meshes):
return specs
class ExportProgress:
"""Use tqdm when available, while keeping Blender builds dependency-free."""
def __init__(self):
self.percent = 0
self.bar = tqdm(total=100, desc="Cesium export", unit="%",
dynamic_ncols=True, mininterval=0.2,
disable=tqdm is None) if tqdm else None
def update(self, stage, percent, detail=""):
percent = max(self.percent, min(100, float(percent)))
delta = percent - self.percent
self.percent = percent
if self.bar:
self.bar.set_description("Cesium export | %s" % stage)
self.bar.set_postfix_str(detail, refresh=False)
if delta:
self.bar.update(delta)
self.bar.refresh()
return
suffix = " - " + detail if detail else ""
print("CESIUM_EXPORT_PROGRESS %3.0f%% [%s]%s" % (percent, stage, suffix), flush=True)
def close(self):
if self.bar:
self.bar.close()
def export(args):
if not os.path.exists(args["blend"]):
raise FileNotFoundError(args["blend"])
progress = ExportProgress()
progress.update("load scene", 1, os.path.basename(args["blend"]))
bpy.ops.wm.open_mainfile(filepath=args["blend"])
material_map = {}
@@ -613,13 +648,13 @@ def export(args):
dynamic_meshes = []
countdown_meshes = {0: [], 1: []}
unwrapped = set()
for obj in bpy.context.scene.objects:
if obj.type != "MESH":
continue
if obj.name == "Ground Plane":
continue
if obj.hide_viewport or obj.hide_render:
continue
scene_objects = list(bpy.context.scene.objects)
candidates = [obj for obj in scene_objects
if obj.type == "MESH" and obj.name != "Ground Plane"
and not obj.hide_viewport and not obj.hide_render]
total_candidates = max(1, len(candidates))
progress.update("prepare scene", 3, "%d mesh objects" % len(candidates))
for index, obj in enumerate(candidates, 1):
if any(c.name == "06_TrafficSignalsDynamic" for c in obj.users_collection):
groups = {slot.material.name for slot in obj.material_slots if slot.material}
group = (0 if any("Countdown Group 0" in name for name in groups)
@@ -650,21 +685,36 @@ def export(args):
if source.name not in material_map:
material_map[source.name] = make_export_material(source)
slot.material = material_map[source.name]
if index == total_candidates or index % max(1, total_candidates // 50) == 0:
progress.update("prepare scene", 3 + 42 * index / total_candidates,
"%d/%d objects, %d materials" % (index, total_candidates, len(material_map)))
export_glb(args["glb"], meshes)
semantic_assets = semantic_asset_specs(args["glb"], meshes)
exports = [(args["glb"], meshes, "main scene")]
if args.get("dynamic_glb"):
if not dynamic_meshes:
raise RuntimeError("Dynamic traffic signal collection is empty")
export_glb(args["dynamic_glb"], dynamic_meshes)
exports.append((args["dynamic_glb"], dynamic_meshes, "dynamic signals"))
for group, key in ((0, "countdown_0_glb"), (1, "countdown_1_glb")):
if not args.get(key):
continue
if not countdown_meshes[group]:
raise RuntimeError("Traffic countdown collection %d is empty" % group)
export_glb(args[key], countdown_meshes[group])
semantic_assets = semantic_asset_specs(args["glb"], meshes)
exports.append((args[key], countdown_meshes[group], "countdown group %d" % group))
for asset in semantic_assets:
export_glb(asset["path"], asset["meshes"])
exports.append((asset["path"], asset["meshes"], "%s layer" % asset["id"]))
total_export_objects = max(1, sum(len(objects) for _, objects, _ in exports))
completed_export_objects = 0
for filepath, objects, label in exports:
start_percent = 45 + 50 * completed_export_objects / total_export_objects
end_percent = 45 + 50 * (completed_export_objects + len(objects)) / total_export_objects
progress.update("export GLB", start_percent,
"%s (%d objects)" % (label, len(objects)))
export_glb(filepath, objects)
completed_export_objects += len(objects)
progress.update("export GLB", end_percent, "%s complete" % label)
progress.update("write metadata", 97)
scene = bpy.context.scene
try:
@@ -711,6 +761,8 @@ def export(args):
json.dump(metadata, handle, ensure_ascii=False, indent=2)
handle.write("\n")
progress.update("complete", 100, "all assets exported")
progress.close()
print("CESIUM_EXPORT_DONE", json.dumps({
"glb": args["glb"], "metadata": args["metadata"],
"meshes": len(meshes), "materials": len(material_map),