Files
que01 c7425f5ed4 feat: make crosswalk and stop-line offsets solvable
Step 1 of the control-marking task, and deliberately server-only: no handle is
drawn yet. This project already shipped a range handle for `profile.interval`,
which compileGeometry ignores, so the control dragged and changed nothing. The
consumer comes first now.

Two kinds join the taxonomy — `junction-crosswalk-inset` and
`junction-stop-line-offset`, both on the existing `junction-approach` anchor. The
solver writes them onto the approach entry, `applyDirectJunctionPlans` carries
them onto the compiled approach, and `compileControlMarkings` reads them in place
of the module constants it used for every junction. They move markings without
reshaping the junction, so unlike width and cutback they deliberately do not
trigger a boundary recompute.

`applyJunctionConstraint` becomes an explicit switch. Its trailing `else` had
meant every kind that was not approach-width fell through to the cutback
validator, so a new kind would have been silently validated and written as a
cutback. The same non-exhaustive shape in the test fixture's `valueFor` is fixed
the same way, and now throws for an unnamed kind rather than answering with a
corner radius.

design.md's taxonomy is updated with it — a test asserts the two cannot drift,
which is what caught the omission.

Measured on a 41-road workspace with 8 crossings: both constraints change their
marking geometry, neither drags the other, and out-of-range blocks instead of
clamping. That measurement is not in the suite: the synthetic junction resolves
`junction_inset_m` to 0 because its crossing never binds to a plan, and the
committed OSM fixture has no crossings at all. The tests assert the wiring the
handles will depend on — values reaching the approach entry, distinct branches,
blocking diagnostics — and the gap is recorded in the test itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 15:01:36 +08:00

15 KiB
Raw Permalink Blame History

直接操纵道路编辑设计

本文是约束模型、合约与边界的唯一权威定义。research/ 下的文档是探索记录;如与本文冲突,以本文为准。

架构边界

OSM、area config 快照和现有 v1 overrides 仍是道路模型的基线。native-road-edits/v2 记录 OSM 语义锚点上的约束与操作,不保存最终 GeoJSON。

OSM + area config snapshot + v1 overrides + v2 direct edits
  -> compileRoadModel
  -> applyRoadOverrides
  -> resolveDirectEditConstraints
  -> editable profiles + junction plans + handle manifest
  -> compileGeometry -> GeoJSON / diagnostics / layers

预览与正式编译共用 resolveDirectEditConstraintscompileGeometry;浏览器不实现第二套道路几何算法。

约束模型

锚点类型(约束挂在哪里)与约束 kind约束什么是两个维度。此前 research/data-model-options.md 与本文混用二者,导致两套命名冲突,此处统一。

type Side = 'left' | 'right'

type SemanticAnchor =
  | { type: 'road-station'; roadId: string; station: number; side?: Side }
  | { type: 'road-interval'; roadId: string; startStation: number; endStation: number; side?: Side }
  | { type: 'junction-approach'; nodeId: string; segmentId: string; side?: Side }
  | { type: 'junction-corner'; nodeId: string; incomingRoadId: string; outgoingRoadId: string }

type RoadConstraintKind =
  | 'road-edge-offset'
  | 'road-sidewalk-width'
  | 'road-lane-divider'
  | 'junction-approach-width'
  | 'junction-cutback'
  | 'junction-corner-radius'
  | 'junction-crosswalk-inset'
  | 'junction-stop-line-offset'
kind 锚点 value 单位与含义 所有者
road-edge-offset road-interval { offsetMeters, transition } 米,相对基线外缘的法向偏移 主地图
road-sidewalk-width road-interval { widthMeters, transition } 米,0 表示关闭步行带 主地图
road-lane-divider road-interval { boundaryIndex, offsetMeters, transition } 米,相对中心线的带符号横向偏移(左负右正) 主地图
junction-approach-width junction-approach { widthMeters } JunctionTools
junction-cutback junction-approach { cutbackMeters } JunctionTools
junction-corner-radius junction-corner { radiusMeters } JunctionTools
junction-crosswalk-inset junction-approach { insetMeters } 米,斑马线距路口边界的退让 主地图 / JunctionTools
junction-stop-line-offset junction-approach { offsetMeters } 米,停止线沿进口后退的距离 主地图 / JunctionTools

前 6 个 kind 对应 PRD 首期范围(外缘、步行带、车道分隔 + 进口、cutback、角部

后 2 个于 2026-08-28 追加,是「更多元素可直接操纵」方向的第一类扩展:控制标线的位置 成为可编辑参数。它们不改变路口形状,只移动标线,因此不触发路口边界重算。 放置是否成立的判据(STOP_LINE_MAX_APPROACH_DISTANCE_METERS 等)不在此范围内。

新增 kind 的前提:几何阶段必须已经消费它。本项目曾为 profile.interval 交付过 一个拖动无效果的手柄(见 08-26-direct-edit-map-editor/research/interval-not-applied.md 所以扩展 kind 时先证明 compileGeometry() 读取该参数,再画任何手柄。

transition: 'smoothstep' | 'linear',默认 smoothstep,作用于 interval 两端回归基线的过渡段。

boundaryIndex 从左外缘起 1-based有效范围 1..laneCount-1。重放时若车道数变化导致越界,该约束转 stale,不做近似映射。

station 是相对道路中心线的归一化弧长 0..1,不是绝对经纬度或数组下标。保存时同时写 anchorSnapshot(当时坐标、切线方位、道路长度、相邻 OSM node ID作为重定位与冲突检测证据。

道路横断面拖拽默认写 road-interval:拖拽位置为区间中心,求解器按道路长度与两端 junction reserve 推导初始 startStation / endStation;用户用范围手柄只更新该区间,不存储鼠标轨迹。

Handle manifest

服务端从同一语义模型生成手柄清单,客户端不自行推导手柄位置或可拖方向。

interface HandleManifest {
  schema: 'road-edit-handles/v1'
  revisionId: string
  previewSeq: number
  handles: EditHandle[]
  reserves: JunctionReserve[]
}

interface EditHandle {
  handleId: string
  kind: RoadConstraintKind
  anchor: SemanticAnchor
  position: [number, number] // EPSG:4326
  axisAzimuth: number // 允许拖拽的方向,度,相对真北
  value: { current: number; min: number; max: number; unit: 'meter' }
  constraintId?: string // 已有约束回链
  affects: string[] // 受影响派生 feature 的 native_id
  editable: boolean
  disabledReason?: string
}

interface JunctionReserve {
  nodeId: string
  roadId: string
  fromStation: number
  toStation: number
}

手柄 feature 的 properties 只携带 handleId,语义通过 manifest 反查。reserves 是主地图禁入区:落在其中的道路手柄必须 editable: false 且给出 disabledReason,引导用户进入 JunctionToolsaffects 用于拖拽时高亮将被同步改变的对象,满足 PRD 第 2 条。

坐标与单位分层

持久化层只有米和归一化 station没有像素、没有度。

坐标 / 单位 规则
持久化约束 米 + station 0..1 禁止写入经纬度或像素
服务端求解 局部米制框架 复用 src/geometry/lane-geometry.jsmetersAt()haversineMeters()projectedDistanceAlong()lateralOffsetFrom(),不新增第二套换算
传输 EPSG:4326 + 方位角 manifest 与预览 GeoJSON 一律 4326
客户端渲染 EPSG:3857 沿用 workbench/client/src/map/layers.ts 现有 dataProjection: 'EPSG:4326' / featureProjection: 'EPSG:3857'

客户端把指针位移换成米时必须先转回 4326 再用球面距离,禁止拿 3857 坐标差当米3857 在纬度 φ 处有 1/cos φ 放大,直接相减会让同一次拖拽在不同纬度得到不同结果。

预览时序与延迟预算

pointer down -> 读 handle 的 anchor建 draft operation
pointer move -> 立即更新客户端 ghost + 递增 previewSeq 的防抖请求
pointer up   -> 强制发一次非防抖的最终请求
  • 防抖:拖拽中 80ms trailingpointerup 不防抖。
  • 时序:每个会话的 previewSeq 单调递增,响应回显它。客户端丢弃 previewSeq 小于已应用最大值的响应,并在发新请求时用 AbortController 取消在途请求。乱序响应绝不允许覆盖较新的预览。
  • 延迟预算:单道路 / 单路口局部求解 p95 ≤ 300ms。超预算仍返回结果但标 degraded: true,客户端保留 ghost 与待定状态,不做几何闪烁。
  • 无效草稿返回 diagnostics 且不替换最后一个有效预览。

文档、活动副本与 revision

活动工作副本包含 v1 overrides、v2 edits、area config 快照和信号数据。导入 OSM 自动创建不可变基线 revision用户命名检查点时冻结所有输入副本、hash、compiler identity 和可选产物。普通保存与预览只更新活动副本。

interface RoadEditDocument {
  schema: 'native-road-edits/v2'
  documentVersion: number
  base: { osmSha256: string; areaConfigSha256: string; compilerGeometryVersion: string }
  constraints: RoadConstraint[]
  operations: RoadEditOperation[]
}

interface RoadConstraint {
  id: string
  kind: RoadConstraintKind
  anchor: SemanticAnchor
  anchorSnapshot: AnchorSnapshot
  value: unknown
  enabled: boolean
  status: ConstraintStatus
  provenance: { operationId: string; createdAt: string; author?: string }
}

type ConstraintStatus = 'exact' | 'recheck' | 'pending' | 'conflicted' | 'stale'
status 含义 是否参与求解
exact 锚点 ID 精确匹配
recheck 锚点匹配,但编译器几何版本已变 是,且需用户显式确认
pending 需按 snapshot 重定位的候选匹配 否,等用户确认
conflicted 候选不唯一或偏差超阈值
stale 锚点已不存在,或参数越界

重导入先按 roadId / segmentId / nodeId 精确匹配;失败才用 anchorSnapshot 的 OSM node、距离与切线找候选。绝不静默模糊应用。

存储布局与迁移

现状是每次导入创建一个 workbench-data/import-<id>/ 目录(workbench/server.jsfs.mkdtempSync),内含 source.osmnative-road-overrides.jsonnative-traffic-signals.jsonoutputs/。新结构在该目录内扩展,不改动既有文件语义:

workbench-data/import-<id>/
  source.osm                     保留v1 路径兼容
  native-road-overrides.json     保留,仍是 v1 覆盖的权威文件
  native-traffic-signals.json    保留
  outputs/                       保留
  osm/<sha256>.osm               内容寻址的 OSM 副本,多 revision 共享
  active/
    native-road-edits.json       v2 直接编辑文档
    area-config.snapshot.json    区域配置快照(含 nativeRoad.junctionTemplates
    state.json                   documentVersion、activeRevisionId
  revisions/
    rev-0001/manifest.json       导入基线
    rev-0002/manifest.json       命名检查点

迁移是惰性的:新版本首次打开既有 import 目录时创建 active/,并把当前 source.osm + overrides + signals + area config 冻结为 rev-0001。不移动、不重写任何既有文件,旧版本仍能读原路径。

留存revision 永不自动删除。OSM 内容寻址后,重复导入同一文件不产生副本。outputs/ 缓存可显式清理,清理不影响可复现性——复现来源始终是输入文档加 compiler identity。

area config 所有权

nativeRoad.junctionTemplates 目前住在项目级区域配置文件里,/api/junction-clusters 直接改写它(workbench/server.js:224-243),旧客户端还提示用户手工复制回配置文件。这让 revision 不可复现:同一份 OSM + overrides 在不同 area config 下编译结果不同。

规则:

  • 每个 revision 与活动副本各持有一份 area-config.snapshot.json
  • 编译与预览只读快照,不在编译期读外部配置文件。
  • /api/junction-clusters 改为写活动副本快照,响应仍返回可复制到项目配置的片段,保留现有导出提示的价值。
  • RoadEditDocument.base.areaConfigSha256 指向快照。
  • cluster 的高级编辑延后,但快照合约现在建立,避免最小 JunctionTools 落地后返工。

编译器几何版本变更

base.compilerGeometryVersion 与当前不一致时,约束仍按精确锚点重放,但全部标 recheck 并要求一次显式"确认重放"不自动改值也不自动判失效。rebase 响应必须给出各 status 的计数与逐条明细。

编辑所有权

主地图 Road editor 只编辑两个 junction reserve 之间的道路内部 interval。reserve 内的 approach width、transition、cutback 和 corner 由 JunctionTools 独占。同一进口上 junction-approach-width 显式约束优先于道路 profile求解器负责边界连续。

JunctionTools 是单 JunctionRef session{type:'node'|'cluster', id}):拖拽立即 ghost服务端返回完整局部派生图层"应用到工作区"把有效草稿合并为活动副本的未保存约束,"保存"才持久化,"取消"回到进入前状态。切换相邻路口前必须应用或取消。首个交付仅支持普通 node junction 的进口宽度、cutback 与一个角部圆角。

不变量

  • 基线 OSM topology 与 centerline 不由直接编辑改变。
  • 单车道最小宽度 2.4m;横断面总宽等于车道、边缘与步行带之和。
  • 同一站点左右外缘不得交叉;相邻 profile 之间必须有可计算的过渡。
  • 进口截面在 cutback 处与路口边界连续;路口面不自交。
  • connector、停止线、人行横道必须落在所属道路 / 路口可用面内,否则产生阻塞性诊断。
  • 无效草稿不替换最后一个有效预览。
  • 每次提交到活动副本是单个可撤销 operation已保存编辑的撤销追加反向操作或禁用约束历史不重写。
  • 普通路口锚定 node复杂 cluster 锚定 cluster 与 arm二者不共用低层约束。

API 合约

新增端点:

端点 行为
GET /api/edit-state 活动文档、documentVersion、revision 元数据、约束状态、handle manifest
POST /api/edit-preview previewSeq + draft operations返回局部 preview 图层、diagnostics、manifest、degraded;不写文件
POST /api/edits 校验并保存活动 v2 文档,要求 expectedDocumentVersion
POST /api/revisions 从活动副本创建命名不可变 revision
POST /api/revisions/:id/rebase 对目标 revision 显式重放,返回各 status 计数与明细

单写者保护:POST /api/editsexpectedDocumentVersion 与服务端不一致时返回 409 与当前版本,不写入。这挡住同机多标签页的静默互相覆盖;多人协作仍延后。

现有端点的变化(字段只增不改):

  • /api/state/api/session 增量返回 activeRevisionIddocumentVersion
  • /api/import 导入成功后自动创建基线 revision返回其 id。
  • /api/overrides 保持 v1 语义;只有无法用 v1 表达的局部编辑进入 v2。
  • /api/junction-clusters 改写活动副本快照而非外部配置文件。
  • 所有写入沿用现有 staging 目录原子替换模式。

上线与回滚形态

客户端编辑能力全程挂在 directEdit 开关后,默认关闭,直到交付级验收标准全部通过。开关关闭时工作台行为与当前 main 完全一致:不注册编辑 interaction、不请求 manifest、不创建 handles/ghost/preview 图层。

服务端是纯增量:新端点独立于既有路由;active/revisions/ 不存在时旧代码路径照常工作v1 native-road-overrides.json 始终保持权威且格式不变。因此任何一步回滚都不会让既有 import-* 目录无法打开,也不需要数据迁移回退脚本。

客户端边界

workbench/client/index.html 只加载 src/main.tsxsendWorkbenchApp() 优先 dist/index.html 并回退到同一个 index.html。因此遗留的 workbench/client/app.js(约 1400 行 vanilla已不是任何入口。本任务只在 React 应用内实现编辑能力,不同步 app.js删除它另开任务。

客户端目前没有单元测试运行器(test:client 只是 tsc --noEmit)。本任务引入 vitestnode 环境覆盖纯逻辑EditSession 命令栈与 undo/redo、previewSeq 乱序丢弃、handle 事件到约束值的投影、米制换算。OpenLayers 地图行为(不重建 Map、图层增量替换implement.md 的手测清单覆盖。