Files
road-compiler/.trellis/tasks/08-26-direct-manipulation-road-editor/design.md
que01 93f09e399e chore(road-editor): plan direct-edit task tree and add client test infra
Planning: parent design.md becomes the single authoritative contract
(constraint model with 6 kinds, handle manifest, coordinate/unit
layering, preview sequencing, storage layout and lazy migration, area
config snapshot, API contract). Work is split into four independently
verifiable child tasks with per-step gates and rollback points.

Test infra: pin vitest 4.1.11, add test:client:unit for client pure
logic, extend prettier globs to root *.ts so vitest.config.ts is checked.

Add .gitignore: the repo had none, so inputs/, outputs/, workbench-data/
and the client build output were untracked rather than ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:33:42 +08:00

254 lines
14 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'
```
| 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 |
这 6 个 kind 与 PRD 首期范围一一对应(外缘、步行带、车道分隔 + 进口、cutback、角部没有多余项也没有缺口。
`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` 的手测清单覆盖。