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>
This commit is contained in:
253
.trellis/tasks/08-26-direct-manipulation-road-editor/design.md
Normal file
253
.trellis/tasks/08-26-direct-manipulation-road-editor/design.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# 直接操纵道路编辑设计
|
||||
|
||||
> 本文是约束模型、合约与边界的唯一权威定义。`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` 的手测清单覆盖。
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user