Files
road-compiler/.trellis/tasks/08-26-direct-manipulation-road-editor/design.md
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

266 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 直接操纵道路编辑设计
> 本文是约束模型、合约与边界的唯一权威定义。`research/` 下的文档是探索记录;如与本文冲突,以本文为准。
## 架构边界
OSM、area config 快照和现有 v1 overrides 仍是道路模型的基线。`native-road-edits/v2` 记录 OSM 语义锚点上的约束与操作,不保存最终 GeoJSON。
```text
OSM + area config snapshot + v1 overrides + v2 direct edits
-> compileRoadModel
-> applyRoadOverrides
-> resolveDirectEditConstraints
-> editable profiles + junction plans + handle manifest
-> compileGeometry -> GeoJSON / diagnostics / layers
```
预览与正式编译共用 `resolveDirectEditConstraints``compileGeometry`;浏览器不实现第二套道路几何算法。
## 约束模型
锚点类型(约束挂在哪里)与约束 kind约束什么是两个维度。此前 `research/data-model-options.md` 与本文混用二者,导致两套命名冲突,此处统一。
```ts
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
服务端从同一语义模型生成手柄清单,客户端不自行推导手柄位置或可拖方向。
```ts
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`,引导用户进入 `JunctionTools``affects` 用于拖拽时高亮将被同步改变的对象,满足 PRD 第 2 条。
## 坐标与单位分层
持久化层只有米和归一化 station没有像素、没有度。
| 层 | 坐标 / 单位 | 规则 |
| --- | --- | --- |
| 持久化约束 | 米 + station `0..1` | 禁止写入经纬度或像素 |
| 服务端求解 | 局部米制框架 | 复用 `src/geometry/lane-geometry.js``metersAt()``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 φ` 放大,直接相减会让同一次拖拽在不同纬度得到不同结果。
## 预览时序与延迟预算
```text
pointer down -> 读 handle 的 anchor建 draft operation
pointer move -> 立即更新客户端 ghost + 递增 previewSeq 的防抖请求
pointer up -> 强制发一次非防抖的最终请求
```
- 防抖:拖拽中 80ms trailing`pointerup` 不防抖。
- 时序:每个会话的 `previewSeq` 单调递增,响应回显它。客户端丢弃 `previewSeq` 小于已应用最大值的响应,并在发新请求时用 `AbortController` 取消在途请求。乱序响应绝不允许覆盖较新的预览。
- 延迟预算:单道路 / 单路口局部求解 p95 ≤ 300ms。超预算仍返回结果但标 `degraded: true`,客户端保留 ghost 与待定状态,不做几何闪烁。
- 无效草稿返回 `diagnostics` 且不替换最后一个有效预览。
## 文档、活动副本与 revision
活动工作副本包含 v1 overrides、v2 edits、area config 快照和信号数据。导入 OSM 自动创建不可变基线 revision用户命名检查点时冻结所有输入副本、hash、compiler identity 和可选产物。普通保存与预览只更新活动副本。
```ts
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.js``fs.mkdtempSync`),内含 `source.osm``native-road-overrides.json``native-traffic-signals.json``outputs/`。新结构在该目录内扩展,不改动既有文件语义:
```text
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/edits``expectedDocumentVersion` 与服务端不一致时返回 409 与当前版本,不写入。这挡住同机多标签页的静默互相覆盖;多人协作仍延后。
现有端点的变化(字段只增不改):
- `/api/state``/api/session` 增量返回 `activeRevisionId``documentVersion`
- `/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.tsx``sendWorkbenchApp()` 优先 `dist/index.html` 并回退到同一个 index.html。因此遗留的 `workbench/client/app.js`(约 1400 行 vanilla已不是任何入口。本任务只在 React 应用内实现编辑能力,不同步 app.js删除它另开任务。
客户端目前没有单元测试运行器(`test:client` 只是 `tsc --noEmit`)。本任务引入 `vitest`node 环境覆盖纯逻辑EditSession 命令栈与 undo/redo、`previewSeq` 乱序丢弃、handle 事件到约束值的投影、米制换算。OpenLayers 地图行为(不重建 Map、图层增量替换`implement.md` 的手测清单覆盖。