From 93f09e399ed2d14758b48c67feb73db0443ebbd0 Mon Sep 17 00:00:00 2001 From: que01 Date: Wed, 26 Aug 2026 17:33:42 +0800 Subject: [PATCH] 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) --- .gitignore | 13 + .../08-26-direct-edit-documents/check.jsonl | 3 + .../08-26-direct-edit-documents/design.md | 18 + .../implement.jsonl | 5 + .../08-26-direct-edit-documents/implement.md | 39 ++ .../tasks/08-26-direct-edit-documents/prd.md | 38 ++ .../08-26-direct-edit-documents/task.json | 26 ++ .../check.jsonl | 4 + .../design.md | 21 + .../implement.jsonl | 6 + .../implement.md | 41 ++ .../08-26-direct-edit-junction-tools/prd.md | 41 ++ .../task.json | 26 ++ .../08-26-direct-edit-map-editor/check.jsonl | 5 + .../08-26-direct-edit-map-editor/design.md | 22 + .../implement.jsonl | 6 + .../08-26-direct-edit-map-editor/implement.md | 49 ++ .../tasks/08-26-direct-edit-map-editor/prd.md | 42 ++ .../08-26-direct-edit-map-editor/task.json | 26 ++ .../08-26-direct-edit-solver-api/check.jsonl | 5 + .../08-26-direct-edit-solver-api/design.md | 22 + .../implement.jsonl | 7 + .../08-26-direct-edit-solver-api/implement.md | 57 +++ .../tasks/08-26-direct-edit-solver-api/prd.md | 42 ++ .../08-26-direct-edit-solver-api/task.json | 26 ++ .../check.jsonl | 5 + .../design.md | 253 ++++++++++ .../implement.jsonl | 8 + .../implement.md | 107 +++++ .../prd.md | 107 +++++ .../research/canvas-integration.md | 108 +++++ .../research/current-system.md | 30 ++ .../research/data-model-options.md | 81 ++++ .../research/drawtonomy.md | 26 ++ .../research/joint-solver.md | 56 +++ .../research/junction-tools.md | 71 +++ .../research/revisions.md | 64 +++ .../task.json | 45 ++ package-lock.json | 436 +++++++++++++++++- package.json | 8 +- vitest.config.ts | 10 + 41 files changed, 2001 insertions(+), 4 deletions(-) create mode 100644 .gitignore create mode 100644 .trellis/tasks/08-26-direct-edit-documents/check.jsonl create mode 100644 .trellis/tasks/08-26-direct-edit-documents/design.md create mode 100644 .trellis/tasks/08-26-direct-edit-documents/implement.jsonl create mode 100644 .trellis/tasks/08-26-direct-edit-documents/implement.md create mode 100644 .trellis/tasks/08-26-direct-edit-documents/prd.md create mode 100644 .trellis/tasks/08-26-direct-edit-documents/task.json create mode 100644 .trellis/tasks/08-26-direct-edit-junction-tools/check.jsonl create mode 100644 .trellis/tasks/08-26-direct-edit-junction-tools/design.md create mode 100644 .trellis/tasks/08-26-direct-edit-junction-tools/implement.jsonl create mode 100644 .trellis/tasks/08-26-direct-edit-junction-tools/implement.md create mode 100644 .trellis/tasks/08-26-direct-edit-junction-tools/prd.md create mode 100644 .trellis/tasks/08-26-direct-edit-junction-tools/task.json create mode 100644 .trellis/tasks/08-26-direct-edit-map-editor/check.jsonl create mode 100644 .trellis/tasks/08-26-direct-edit-map-editor/design.md create mode 100644 .trellis/tasks/08-26-direct-edit-map-editor/implement.jsonl create mode 100644 .trellis/tasks/08-26-direct-edit-map-editor/implement.md create mode 100644 .trellis/tasks/08-26-direct-edit-map-editor/prd.md create mode 100644 .trellis/tasks/08-26-direct-edit-map-editor/task.json create mode 100644 .trellis/tasks/08-26-direct-edit-solver-api/check.jsonl create mode 100644 .trellis/tasks/08-26-direct-edit-solver-api/design.md create mode 100644 .trellis/tasks/08-26-direct-edit-solver-api/implement.jsonl create mode 100644 .trellis/tasks/08-26-direct-edit-solver-api/implement.md create mode 100644 .trellis/tasks/08-26-direct-edit-solver-api/prd.md create mode 100644 .trellis/tasks/08-26-direct-edit-solver-api/task.json create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/check.jsonl create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/design.md create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/implement.jsonl create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/implement.md create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/prd.md create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/research/canvas-integration.md create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/research/data-model-options.md create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/research/drawtonomy.md create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/research/revisions.md create mode 100644 .trellis/tasks/08-26-direct-manipulation-road-editor/task.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb69531 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ + +# 本机独有的编译输入(体积大、按区域各异,不入库) +inputs/ + +# 编译与测试产物 +outputs/ +workbench/client/dist/ + +# 工作台运行期数据:导入的 OSM、overrides、信号与派生产物 +workbench-data/ + +.DS_Store diff --git a/.trellis/tasks/08-26-direct-edit-documents/check.jsonl b/.trellis/tasks/08-26-direct-edit-documents/check.jsonl new file mode 100644 index 0000000..5472963 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-documents/check.jsonl @@ -0,0 +1,3 @@ +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "逐条核对文档 schema、5 态 status、存储布局与快照规则是否与权威合约一致"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "检查是否出现第二套 hash、第二套 ID 校验或重复的 overrides 读写逻辑"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "核对 v1 overrides 文件格式与既有 import 目录未被改写"} diff --git a/.trellis/tasks/08-26-direct-edit-documents/design.md b/.trellis/tasks/08-26-direct-edit-documents/design.md new file mode 100644 index 0000000..49c80e9 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-documents/design.md @@ -0,0 +1,18 @@ +# 设计 + +技术合约不在本文重复定义。权威定义见父任务 `.trellis/tasks/08-26-direct-manipulation-road-editor/design.md` 的以下小节: + +- 「约束模型」— `RoadConstraintKind` 6 个取值、`SemanticAnchor` 4 种锚点、`transition`、`boundaryIndex` 范围、`station` 语义。 +- 「文档、活动副本与 revision」— `RoadEditDocument`、`RoadConstraint`、`ConstraintStatus` 5 态表。 +- 「存储布局与迁移」— 目录结构、惰性迁移、内容寻址与留存策略。 +- 「area config 所有权」— 快照规则与 `areaConfigSha256`。 +- 「编译器几何版本变更」— `recheck` 策略。 +- 「上线与回滚形态」— 服务端纯增量、v1 文件不动。 + +## 本子任务的局部决定 + +- 文档读写与 schema 校验放在编译器侧(`src/compile/` 下新增模块),不放在 `workbench/server.js`:预览、正式编译与 CLI 导出都要用它,放服务器会造成第二套实现。 +- 校验只做结构与取值范围,不做几何可行性。几何不变量(最小车道宽、外缘不交叉等)属求解器职责,见 `direct-edit-solver-api`。 +- 惰性迁移在读取入口触发,不提供独立迁移命令:既有 import 目录数量少,且迁移是纯增量创建,失败可重试。 +- `ConstraintStatus` 在本子任务只落地存储与校验;实际重放匹配逻辑属 `direct-edit-solver-api`。本任务的测试用手工构造的 status 值覆盖读写路径。 +- 内容寻址采用 `sha256`,与 `RoadRevisionManifest.source.osmSha256` 同一摘要,避免两套 hash。 diff --git a/.trellis/tasks/08-26-direct-edit-documents/implement.jsonl b/.trellis/tasks/08-26-direct-edit-documents/implement.jsonl new file mode 100644 index 0000000..91c4852 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-documents/implement.jsonl @@ -0,0 +1,5 @@ +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "父任务权威合约:约束模型、文档 schema、存储布局与迁移、area config 快照、编译器版本策略"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/revisions.md", "reason": "RoadRevisionManifest 字段来源与不可变 revision 的设计理由"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "现有 ID 语义、v1 overrides 校验与 stale diagnostics 的可复用边界"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "sha256 摘要、ID 校验与 overrides 应用必须复用既有实现,不得新建第二套"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "新增 JSONL 文档与配置字段跨编译器/服务端/客户端,属该指南触发条件"} diff --git a/.trellis/tasks/08-26-direct-edit-documents/implement.md b/.trellis/tasks/08-26-direct-edit-documents/implement.md new file mode 100644 index 0000000..449bffc --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-documents/implement.md @@ -0,0 +1,39 @@ +# 实施计划 + +对应父任务 `implement.md` 的第 0、2、3 步。每步一个提交,门禁不过就停。 + +## 1. 测试基础设施 + +- 目标:先建验证能力,避免后续"写完没法测"。 +- 范围:`package.json` 增加固定版本 `vitest` 与 `test:client:unit` 脚本;建立客户端纯逻辑测试目录。不碰产品代码。 +- 验证:`npm run test:client:unit`(空套件通过)、`npm run format:check`、`npm run test`、`npm run test:client`、`npm run build`。 +- 门禁:新脚本可跑通且不影响任何既有脚本。 +- 回滚点:仅 `package.json` 与测试目录,revert 无副作用。 + +## 2. v2 文档与 schema 校验 + +- 目标:`native-road-edits/v2` 的读写与结构校验。 +- 范围:`src/compile/` 下新增文档模块:6 个 kind、4 种锚点、`anchorSnapshot`、`ConstraintStatus`、`documentVersion`、`operations`。仅结构与取值范围校验。 +- 验证:`npm run test` 新增 fixture——文档往返无损;越界 `boundaryIndex`(`<1` 或 `>=laneCount`)被拒;非法 station(超出 `0..1`、`start>=end`)被拒;未知 kind 被拒;每次写入 `documentVersion` 递增 1。 +- 门禁:校验错误信息能指出具体约束 id 与字段,不是笼统失败。 +- 回滚点:纯新增模块,无调用方,revert 无影响。 + +## 3. 存储布局、惰性迁移与 revision + +- 目标:`active/`、`revisions/`、内容寻址 OSM 副本落地,既有目录可平滑接入。 +- 范围:目录布局读写;读取入口触发的惰性迁移,把当前 `source.osm` + overrides + signals + area config 冻结为 `rev-0001`;命名检查点创建;revision 恢复读取;`RoadRevisionManifest` 写入。 +- 验证:`npm run test` 新增 fixture——只含旧文件的目录被打开后生成 `rev-0001` 且既有文件字节不变;内容相同的 OSM 重复导入不产生第二份副本;命名检查点冻结后可完整恢复读取;manifest digest 与实际文件一致。 +- 门禁:迁移对既有 `workbench-data/import-*` 只增不改;revert 后旧代码仍能打开这些目录。 +- 回滚点:revert 后 `active/` 与 `revisions/` 只是残留目录,旧代码忽略它们。 + +## 4. area config 快照 + +- 目标:切断编译对外部区域配置文件的运行期依赖。 +- 范围:活动副本与每个 revision 各写一份 `area-config.snapshot.json`;编译改读快照;`base.areaConfigSha256` 指向快照。 +- 验证:`npm run test` 新增 fixture——冻结 revision 后修改外部 area config,重新编译该 revision 结果不变;快照缺失时报明确错误而非静默读外部文件。 +- 门禁:无 v2 文档时编译输出与快照一致。注意:仓库现有 `test/baseline/*.json` 已过期(`contract` 为 `native-road-package/v1`,当前为 `v1.1`,18 个文件 hash 全部不符),且 `test/fixtures.js:26` 只断言文件存在、从不比对内容,仓库内也没有生成器。因此本步须先从当前 main 重新生成快照并接上真实比对,再谈"不变"。 +- 回滚点:revert 后编译回到读外部配置文件;已写快照被忽略。 + +## 步骤依赖 + +1 → 2 → 3 → 4 是硬顺序。第 4 步的 baseline 比对是本子任务能否交付的总门禁:无 v2 约束时编译输出必须与当前 main 完全一致。 diff --git a/.trellis/tasks/08-26-direct-edit-documents/prd.md b/.trellis/tasks/08-26-direct-edit-documents/prd.md new file mode 100644 index 0000000..c882080 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-documents/prd.md @@ -0,0 +1,38 @@ +# 直接编辑文档与 revision 基础 + +父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。需求来源与权威合约在父任务的 `prd.md` / `design.md`。 + +## 目标 + +建立 `native-road-edits/v2` 文档、活动工作副本、不可变 revision 与 area config 快照,使后续求解、预览和编辑能力有可复现的持久化基础。本子任务不含任何 UI,也不含约束求解。 + +## 顺序依赖 + +无前置。是 `direct-edit-solver-api`、`direct-edit-map-editor`、`direct-edit-junction-tools` 三者的前置:它们依赖本任务定义的文档 schema、`documentVersion` 与快照读取路径。 + +## 范围 + +- `vitest` 依赖与 `test:client:unit` 脚本(后续子任务的验证基础)。 +- `native-road-edits/v2` 文档读写与 schema 校验:6 个约束 kind、4 种锚点、`anchorSnapshot`、5 态 `ConstraintStatus`、`documentVersion`。 +- `workbench-data/import-/` 内的 `active/`、`revisions/`、内容寻址 `osm/.osm` 布局。 +- 既有 import 目录的惰性迁移:首次打开时冻结 `rev-0001`,不移动或重写既有文件。 +- `area-config.snapshot.json` 的写入与读取;编译与预览改读快照。 +- 命名检查点创建与 revision 恢复读取。 + +## 不做 + +- `resolveDirectEditConstraints` 与任何几何求解(属 `direct-edit-solver-api`)。 +- 预览、保存、rebase 的 HTTP 端点(属 `direct-edit-solver-api`)。 +- 任何客户端改动。 +- v1 `native-road-overrides.json` 的迁移或格式变更。 + +## 验收标准 + +- [ ] `vitest` 与 `test:client:unit` 可跑通空套件,且不影响既有 `test` / `test:client` / `build`。 +- [ ] v2 文档往返读写无损;schema 校验拒绝越界 `boundaryIndex`、非法 station 与未知 kind。 +- [ ] 每次成功写入使 `documentVersion` 递增 1。 +- [ ] 只含旧文件的既有 `workbench-data/import-*` 目录,在新代码下可正常打开且未被改写,并生成 `rev-0001`。 +- [ ] 内容相同的 OSM 重复导入不产生第二份副本。 +- [ ] 冻结 revision 后修改外部 area config,重新编译该 revision 结果不变。 +- [ ] 快照缺失时给出明确错误,不静默回退到读外部配置文件。 +- [ ] `npm run format:check`、`npm run test`、`npm run test:client`、`npm run build` 全绿。 diff --git a/.trellis/tasks/08-26-direct-edit-documents/task.json b/.trellis/tasks/08-26-direct-edit-documents/task.json new file mode 100644 index 0000000..cacf376 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-documents/task.json @@ -0,0 +1,26 @@ +{ + "id": "direct-edit-documents", + "name": "direct-edit-documents", + "title": "直接编辑文档与 revision 基础", + "description": "native-road-edits/v2 文档、活动副本、不可变 revision、area config 快照与惰性迁移", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-26", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-26-direct-manipulation-road-editor", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-26-direct-edit-junction-tools/check.jsonl b/.trellis/tasks/08-26-direct-edit-junction-tools/check.jsonl new file mode 100644 index 0000000..9ed8853 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-junction-tools/check.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md", "reason": "逐条核对四态语义、会话互斥与「应用不等于保存」是否被正确实现"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "核对路口三个 kind 的锚点、approach 优先级与 reserve 独占边界"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "核对预览是否覆盖全部受影响派生对象,以及拟合失败时的诊断行为"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "检查是否重复实现 EditSession、命令栈或 ghost/preview 图层"} diff --git a/.trellis/tasks/08-26-direct-edit-junction-tools/design.md b/.trellis/tasks/08-26-direct-edit-junction-tools/design.md new file mode 100644 index 0000000..cf5b21a --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-junction-tools/design.md @@ -0,0 +1,21 @@ +# 设计 + +技术合约不在本文重复定义。权威定义见父任务 `.trellis/tasks/08-26-direct-manipulation-road-editor/design.md` 的以下小节: + +- 「约束模型」— `junction-approach-width`、`junction-cutback`、`junction-corner-radius` 三个 kind 与其锚点。 +- 「编辑所有权」— junction reserve 独占、`junction-approach-width` 优先于道路 profile、单 `JunctionRef` 会话。 +- 「Handle manifest」— 手柄字段与 `reserves`。 +- 「预览时序与延迟预算」— 防抖、`previewSeq` 仲裁、`degraded`。 +- 「上线与回滚形态」— `directEdit` 开关。 + +会话状态、四态语义与所有权边界的完整推导见父任务 `research/junction-tools.md`。 + +## 本子任务的局部决定 + +- 工作区是独立路由,不是主地图上的浮层面板:路口编辑是高密度局部操作,叠在主地图上会与道路手柄争夺命中区。 +- 复用 `direct-edit-map-editor` 的 `EditSession`、ghost/preview source 与命令栈,不新建第二套编辑基础设施。差异只在加载的上下文范围与可用 kind。 +- 草稿属于活动工作副本的 session,不是独立导出文件。因此「取消」只需丢弃 session,不涉及文件回滚。 +- 「应用到工作区」不写盘:合并后的约束是活动副本的未保存状态,交给主地图的普通 undo/redo 管理。这样保存路径只有一条。 +- 会话互斥用显式守卫实现:存在未处理草稿时切换 `JunctionRef` 直接拒绝并提示,不做自动应用或自动丢弃——两者都会让用户丢失意图。 +- 本期只实现 `JunctionRef.type === 'node'`。`cluster` 分支保留类型但入口置灰,避免把 cluster 降级成多个互相冲突的普通路口编辑。 +- 单个角部圆角:本期只允许编辑一个 corner,多角部批量编辑属后续任务。 diff --git a/.trellis/tasks/08-26-direct-edit-junction-tools/implement.jsonl b/.trellis/tasks/08-26-direct-edit-junction-tools/implement.jsonl new file mode 100644 index 0000000..6898d5d --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-junction-tools/implement.jsonl @@ -0,0 +1,6 @@ +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "父任务权威合约:三个 junction kind、编辑所有权、handle manifest、预览时序、directEdit 开关"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md", "reason": "会话边界、四态语义、入口与上下文加载范围、所有权划分的完整推导"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "路口局部求解必须一起重算的派生对象清单与约束不变量"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "现有 compileJunctionPlans、cutbackMeters 与 junctionTemplates 的能力边界"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "必须复用 EditSession、ghost/preview source 与命令栈,禁止新建第二套编辑基础设施"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "会话状态与约束在客户端、API、求解器之间的流转一致性"} diff --git a/.trellis/tasks/08-26-direct-edit-junction-tools/implement.md b/.trellis/tasks/08-26-direct-edit-junction-tools/implement.md new file mode 100644 index 0000000..470a9c0 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-junction-tools/implement.md @@ -0,0 +1,41 @@ +# 实施计划 + +对应父任务 `implement.md` 的第 7 步。每步一个提交,门禁不过就停。 + +前置:`direct-edit-solver-api`(junction 三个 kind 的求解与预览端点)与 `direct-edit-map-editor`(`EditSession`、ghost/preview source、命令栈)均已交付。 + +## 1. 工作区路由与上下文加载 + +- 目标:能进能回,且不丢全局方位。 +- 范围:单 `JunctionRef` 路由(本期只走 `node`,`cluster` 入口置灰);从路口面或诊断进入;加载该路口的编辑上下文(路口面、进口、相邻道路短上下文、车道/connector、步行带、停止线、斑马线、诊断、当前约束);返回主地图并恢复位置。 +- 验证:`npm run test:client`、`npm run build`;手测——从路口面进入、从诊断进入、返回后主地图位置与选中态保持。 +- 门禁:`cluster` 入口置灰且有原因提示,不落到 node 分支。 +- 回滚点:纯新增路由,`directEdit` 开关关闭即隐藏入口。 + +## 2. 会话状态机与互斥守卫 + +- 目标:四态语义先于几何落地,避免后续用几何调试状态。 +- 范围:`draft` / `applied` / `saved` / `cancelled` 的纯逻辑;未处理草稿时切换 `JunctionRef` 的拒绝守卫;取消复原。复用 `EditSession`,不新建第二套命令栈。 +- 验证:`npm run test:client:unit` —— draft → 应用后成为主工作区未保存约束;取消后活动副本无残留;存在未处理草稿时切换被拒绝且草稿不丢;应用后的编辑可被主地图 undo/redo 撤销。 +- 门禁:切换拒绝路径有测试;不存在自动应用或自动丢弃草稿的分支。 +- 回滚点:纯逻辑模块,revert 后回到第 1 步的只读工作区。 + +## 3. 三项可拖能力与完整预览 + +- 目标:进口宽度、cutback、单角部圆角真正可编辑,预览覆盖全部受影响对象。 +- 范围:三类手柄的渲染与拖拽;首次拖拽即 ghost;防抖后服务端局部权威预览;预览覆盖道路面、路口面、步行带、车道中心线、connector/movement、停止线、斑马线、标线;拟合失败返回最后有效几何加结构化诊断。 +- 验证:`npm run test:client:unit`(手柄事件到三个 kind 的约束值投影)、`npm run test`(局部求解与全量编译对同一约束集结果一致)、`npm run test:client`、`npm run build`;手测——三项各自可拖;预览包含上述全部对象;制造拟合失败时不把非法图形显示成已应用。 +- 门禁:预览对象清单逐项核对通过;失败态显示最后有效几何而非空白或非法图形。 +- 回滚点:按 kind 分批提交,可单独回退某一类手柄。 + +## 4. 闭环验证 + +- 目标:与主工作区和持久化链路接通。 +- 范围:应用回主地图、保存、重新编译的完整往返;`expectedDocumentVersion` 复用主地图已实现的冲突处理。 +- 验证:`npm run format:check`、`npm run test`、`npm run test:client`、`npm run test:client:unit`、`npm run build`;手测——应用后返回主地图显示同一几何;保存后重新编译几何不变且约束状态 `exact`。 +- 门禁:`prd.md` 全部验收标准勾选。 +- 回滚点:revert 后回到第 3 步的预览-only 状态。 + +## 步骤依赖 + +1 → 2 → 3 → 4 硬顺序。第 2 步先于第 3 步是刻意的:会话四态若在几何可拖之后才做,取消与互斥的缺陷会被误当成求解问题。 diff --git a/.trellis/tasks/08-26-direct-edit-junction-tools/prd.md b/.trellis/tasks/08-26-direct-edit-junction-tools/prd.md new file mode 100644 index 0000000..0206f50 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-junction-tools/prd.md @@ -0,0 +1,41 @@ +# 最小 JunctionTools + +父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。需求来源与权威合约在父任务的 `prd.md` / `design.md`。 + +## 目标 + +用专用工作区承载路口编辑,验证单一普通路口的进口宽度、cutback 与单个角部圆角,闭合"预览 → 应用到工作区 → 保存 → 取消"四态。这是路口能力的最小可信切片,不是完整路口编辑器。 + +## 顺序依赖 + +前置:`direct-edit-solver-api`(junction 相关的三个 kind 求解与预览端点)与 `direct-edit-map-editor`(`EditSession`、ghost/preview 图层、命令栈)。 +后继:无。cluster 高级编辑、多路口联动为独立后续任务。 + +## 范围 + +- 单 `JunctionRef`(`{type:'node'|'cluster', id}`,本期只走 `node`)的工作区路由与返回主地图的上下文保持。 +- 加载该路口的编辑上下文:路口面、进口、相邻道路短上下文、车道/connector、步行带、停止线、斑马线、诊断与当前约束。 +- 三项可拖能力:进口宽度、cutback、单个角部圆角。 +- session draft:首次拖拽即 ghost,防抖后服务端权威局部预览。 +- 「应用到工作区」合并有效草稿为活动副本的未保存约束;「保存」持久化;「取消」丢弃草稿并复原。 +- 会话边界:切换相邻路口前必须应用或取消。 + +## 不做 + +- cluster 的高级编辑与复合路口模板编排。 +- 多个路口的联合会话或联动编辑。 +- 控制设施的逐个手工布置。 +- 主地图道路区间的编辑(属 `direct-edit-map-editor`)。 +- OSM 图拓扑的修改。 + +## 验收标准 + +- [ ] 从路口面或诊断可进入工作区,并能带位置返回主地图。 +- [ ] 三项能力各自可拖,首次拖拽即有 ghost,防抖后显示服务端权威几何。 +- [ ] 预览包含全部受影响派生对象:道路面、路口面、步行带、车道中心线、connector/movement、停止线、斑马线、标线。 +- [ ] 拟合失败时返回最后有效几何加结构化诊断,不把非法图形显示成已应用。 +- [ ] 「应用到工作区」后返回主地图仍显示同一几何,且该编辑处于未保存状态、可被主地图 undo/redo 管理。 +- [ ] 「取消」后回到进入工作区前的状态,活动副本无残留。 +- [ ] 未处理草稿时切换相邻路口被拒绝,并提示先应用或取消。 +- [ ] 「保存」后重新编译几何不变,约束状态 `exact`。 +- [ ] `npm run format:check`、`npm run test`、`npm run test:client`、`npm run test:client:unit`、`npm run build` 全绿。 diff --git a/.trellis/tasks/08-26-direct-edit-junction-tools/task.json b/.trellis/tasks/08-26-direct-edit-junction-tools/task.json new file mode 100644 index 0000000..f45b5b8 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-junction-tools/task.json @@ -0,0 +1,26 @@ +{ + "id": "direct-edit-junction-tools", + "name": "direct-edit-junction-tools", + "title": "最小 JunctionTools", + "description": "单一普通路口的进口宽度、cutback、单角部圆角与预览/应用/取消", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-26", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-26-direct-manipulation-road-editor", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-26-direct-edit-map-editor/check.jsonl b/.trellis/tasks/08-26-direct-edit-map-editor/check.jsonl new file mode 100644 index 0000000..1bf59c1 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-map-editor/check.jsonl @@ -0,0 +1,5 @@ +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "核对坐标分层(禁止用 3857 坐标差当米)、预览时序仲裁位置与开关关闭时的零残留要求"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/canvas-integration.md", "reason": "核对基线图层保持只读、手柄只带 handleId、未把地理坐标直接写成道路 polygon"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "检查客户端是否出现第二份约束模型或对 manifest 字段的局部 cast"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "检查是否重复实现图层更新、选择高亮或米制换算"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "核对 Map 单例未被重建、既有图层可见性与选择行为未回归"} diff --git a/.trellis/tasks/08-26-direct-edit-map-editor/design.md b/.trellis/tasks/08-26-direct-edit-map-editor/design.md new file mode 100644 index 0000000..3ccb222 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-map-editor/design.md @@ -0,0 +1,22 @@ +# 设计 + +技术合约不在本文重复定义。权威定义见父任务 `.trellis/tasks/08-26-direct-manipulation-road-editor/design.md` 的以下小节: + +- 「Handle manifest」— 客户端读取的字段与 `reserves` 禁入区语义。 +- 「坐标与单位分层」— 客户端渲染用 EPSG:3857,指针位移换米必须先回 4326 再用球面距离。 +- 「预览时序与延迟预算」— 80ms 防抖、`pointerup` 最终请求、`previewSeq` 乱序丢弃、`degraded` 处理。 +- 「交互与预览」「编辑所有权」— 只读基线图层、三个独立 source、道路区间与 junction reserve 的边界。 +- 「客户端边界」— 遗留 `app.js` 不在范围、`vitest` 覆盖范围。 +- 「上线与回滚形态」— `directEdit` 开关默认关闭时的行为。 + +输入 adapter 的候选、探针退出条件与回退优先级见父任务 `research/canvas-integration.md`。 + +## 本子任务的局部决定 + +- 三个 source 严格分离:`editHandles`(手柄)、`editGhost`(客户端即时轮廓与辅助线)、`editPreview`(服务端权威几何)。基线图层始终只读,绝不写入。 +- 手柄 feature 的 properties 只带 `handleId`,语义一律回 manifest 查。这样客户端没有第二份约束模型。 +- ghost 只画手柄、辅助线与半透明预估轮廓,不承担权威几何。服务端预览到达后替换 `editPreview`,ghost 随即淡出。 +- `EditSession` 是纯逻辑对象,不持有 OpenLayers 引用:命令栈、undo/redo、`previewSeq` 仲裁都可在 node 环境下单测。OL 只作为它的输入输出适配层。 +- `previewSeq` 仲裁放在 `EditSession` 而非请求层:请求层只负责发与取消,丢弃决策要可测。 +- 探针独立成可删除目录,不进 `MapCanvas`。探针结论只影响"手柄命中与拖拽由谁实现",不影响上面任何一条。 +- 开关关闭时不注册 interaction、不请求 manifest、不创建三个 source,确保零残留开销。 diff --git a/.trellis/tasks/08-26-direct-edit-map-editor/implement.jsonl b/.trellis/tasks/08-26-direct-edit-map-editor/implement.jsonl new file mode 100644 index 0000000..f201f2a --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-map-editor/implement.jsonl @@ -0,0 +1,6 @@ +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "父任务权威合约:handle manifest 字段、坐标分层、预览时序、编辑所有权、客户端边界、directEdit 开关"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/canvas-integration.md", "reason": "ol-ext 探针的退出条件、回退优先级,以及为何不引入第二个 Canvas/视口"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "EditSession 的交互序列与浏览器 ghost 的职责边界"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "现有单例 Map、source registry 与选择高亮的可复用点"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "manifest 字段从服务端到客户端类型的流转,避免局部 cast 与重复状态"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "图层与 source 更新必须复用现有 layers.ts 模式,米制换算不得重写"} diff --git a/.trellis/tasks/08-26-direct-edit-map-editor/implement.md b/.trellis/tasks/08-26-direct-edit-map-editor/implement.md new file mode 100644 index 0000000..24a3a54 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-map-editor/implement.md @@ -0,0 +1,49 @@ +# 实施计划 + +对应父任务 `implement.md` 的第 1、6 步。每步一个提交,门禁不过就停。 + +前置:`direct-edit-solver-api` 第 2 步(handle manifest)已可用。完整验收需其预览与保存端点就绪。 + +## 1. `ol-ext` 限时探针 + +- 目标:只回答一个问题——能否复用通用 handle 的命中、pointer 生命周期与视觉反馈。 +- 范围:隔离探针目录,不修改生产基线图层,不进依赖清单直到通过。 +- 验证:手动跑探针页,确认 OL `Map` 未重建、proxy 拖拽稳定、基线 source 未被写入、事件能转成 draft 值。 +- 门禁:三条同时成立才把 `ol-ext` 固定为依赖。超过一个工作日或任一条不成立,立刻停止排障。 +- 回滚点:删除探针目录,改用原生 OL `Snap` + 小型 `PointerInteraction` adapter。数据合约不变,本步失败只换输入层。 + +## 2. `EditSession` 纯逻辑 + +- 目标:先把可单测的部分做完,与地图解耦。 +- 范围:命令栈与 undo/redo;`previewSeq` 仲裁;handle 事件到约束值的投影;指针位移到米的换算。不含任何 OpenLayers 引用。 +- 验证:`npm run test:client:unit` —— 命令栈压入/撤销/重做序列正确;乱序响应(后发先到)被丢弃且不覆盖较新预览;投影在四种 kind 上给出预期约束值;同一像素位移在低纬与高纬得到一致的米值(纬度缩放正确)。 +- 门禁:四类纯逻辑全部有测试;`EditSession` 不 import `ol`。 +- 回滚点:纯新增模块,无调用方,revert 无影响。 + +## 3. 手柄渲染与开关 + +- 目标:选中道路后能看到正确的手柄,且禁入区可见可解释。 +- 范围:`directEdit` 开关(默认关闭);selection → manifest 读取;`editHandles` source 渲染;reserve 内手柄置灰并带原因提示。只读不可拖。 +- 验证:`npm run test:client`、`npm run build`;手测——选中道路出现手柄,拖拽前后 `Map` 为同一实例;reserve 内手柄置灰并提示进入 `JunctionTools`;关闭开关后无手柄、无 manifest 请求。 +- 门禁:`Map` 未被重建;基线 source 未被写入。 +- 回滚点:开关关闭即止损。 + +## 4. 拖拽、ghost 与预览 + +- 目标:四类能力真正可拖,预览权威且不闪烁。 +- 范围:外缘偏移、步行带宽度、车道分隔位置、区间范围控制;`editGhost` 即时反馈;80ms 防抖与 `pointerup` 最终请求;`AbortController` 取消在途请求;`editPreview` 替换;无效草稿诊断与保留最后有效预览。 +- 验证:`npm run test:client:unit`、`npm run test:client`、`npm run build`;手测——拖动外缘时道路面/步行带/车道线/标线/connector 一致更新且只有受影响 source 被替换;车道分隔可独立调整;区间范围手柄改变影响区间且两端平滑;连续快速拖拽松手后无回跳;违反不变量时停在最后有效预览并显示诊断。 +- 门禁:只有受影响 source 被替换,无全图层重建;无回跳。 +- 回滚点:按能力分批提交,可单独回退某一类手柄。 + +## 5. 应用、保存与撤销 + +- 目标:闭合编辑生命周期。 +- 范围:应用/保存/取消;undo/redo 接到命令栈;保存走 `expectedDocumentVersion`;409 冲突提示。 +- 验证:`npm run test:client:unit`(保存冲突分支)、`npm run test:client`、`npm run build`;手测——未保存编辑可撤销/重做;保存后重新编译几何不变且状态 `exact`;两标签页同时保存后者收到 409 提示且前者结果保留。 +- 门禁:撤销不重写已保存历史;409 有明确用户提示而非静默失败。 +- 回滚点:revert 后回到第 4 步的预览-only 状态。 + +## 步骤依赖 + +1 与 2 可并行;3 依赖 1 的结论与 2;4 依赖 3;5 依赖 4。第 1 步失败不阻塞 2-5,只改变第 3、4 步的命中与拖拽实现方式。 diff --git a/.trellis/tasks/08-26-direct-edit-map-editor/prd.md b/.trellis/tasks/08-26-direct-edit-map-editor/prd.md new file mode 100644 index 0000000..ec09fcf --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-map-editor/prd.md @@ -0,0 +1,42 @@ +# 主地图道路区间编辑器 + +父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。需求来源与权威合约在父任务的 `prd.md` / `design.md`。 + +## 目标 + +在主地图上实现道路内部区间的直接操纵:外缘、步行带、车道分隔与区间范围可拖,拖拽即时有 ghost,服务端返回权威预览,改动可撤销、可保存。 + +## 顺序依赖 + +前置:`direct-edit-solver-api` 的第 2 步(handle manifest)交付后即可开工;完整验收需要其预览与保存端点全部就绪。 +后继:`direct-edit-junction-tools` 复用本任务的 `EditSession`、ghost/preview 图层与命令栈。 + +## 范围 + +- `ol-ext` Transform 的限时隔离探针,以及不通过时的原生 OpenLayers 回退。 +- selection → handle manifest 的读取与渲染;handles / ghost / preview 三个独立 source。 +- 四类可拖能力:外缘偏移、步行带宽度、车道分隔位置、区间范围控制。 +- `EditSession`:命令栈、undo/redo、应用/保存/取消。 +- 80ms 防抖预览、`pointerup` 强制最终请求、`previewSeq` 乱序丢弃、`AbortController` 取消在途请求。 +- 无效草稿的诊断反馈,保留最后一个有效预览。 +- `directEdit` 开关,默认关闭。 + +## 不做 + +- 路口 reserve 内的任何编辑(属 `direct-edit-junction-tools`)。 +- 遗留 `workbench/client/app.js` 的同步或删除。 +- OSM 中心线与拓扑的编辑。 + +## 验收标准 + +- [ ] 选中道路后出现手柄,拖拽前后 OpenLayers `Map` 为同一实例,未被重建。 +- [ ] 拖动外缘 → 道路面、步行带、车道线、标线、connector 一致更新,只有受影响 source 被替换。 +- [ ] 车道分隔手柄可独立调整某条分隔线,效果不等价于外缘偏移的副作用。 +- [ ] 区间范围手柄可修改影响区间,两端平滑过渡回基线。 +- [ ] 落在 junction reserve 内的手柄不可拖动,并提示进入 `JunctionTools`。 +- [ ] 连续快速拖拽后松手,最终几何与松手位置一致,无回跳。 +- [ ] 违反不变量的草稿显示阻塞诊断,地图停在最后一个有效预览。 +- [ ] 未保存编辑可撤销/重做;保存后重新编译几何不变,约束状态 `exact`。 +- [ ] 客户端单元测试覆盖:命令栈与 undo/redo、`previewSeq` 乱序丢弃、handle 事件到约束值的投影、米制换算在不同纬度的正确性。 +- [ ] 关闭 `directEdit` 开关后,工作台行为与当前 main 一致。 +- [ ] `npm run format:check`、`npm run test`、`npm run test:client`、`npm run test:client:unit`、`npm run build` 全绿。 diff --git a/.trellis/tasks/08-26-direct-edit-map-editor/task.json b/.trellis/tasks/08-26-direct-edit-map-editor/task.json new file mode 100644 index 0000000..a953020 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-map-editor/task.json @@ -0,0 +1,26 @@ +{ + "id": "direct-edit-map-editor", + "name": "direct-edit-map-editor", + "title": "主地图道路区间编辑器", + "description": "ol-ext 限时探针与主地图外缘/步行带/车道分隔/区间范围拖拽", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-26", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-26-direct-manipulation-road-editor", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-26-direct-edit-solver-api/check.jsonl b/.trellis/tasks/08-26-direct-edit-solver-api/check.jsonl new file mode 100644 index 0000000..5091451 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-solver-api/check.jsonl @@ -0,0 +1,5 @@ +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "逐字段核对 handle manifest、API 合约、409 前置条件与 degraded 标记是否与权威合约一致"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "逐条核对约束不变量:最小车道宽 2.4m、外缘不交叉、路口连续、路口面不自交、connector 包含性"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "检查是否出现第二套米制换算或重复的 overrides 应用逻辑"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "检查约束 kind 与 manifest 字段在编译器、服务端响应之间是否各写一套"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "核对既有 /api/overrides、/api/compile、/api/state 行为与 baseline 输出未回归"} diff --git a/.trellis/tasks/08-26-direct-edit-solver-api/design.md b/.trellis/tasks/08-26-direct-edit-solver-api/design.md new file mode 100644 index 0000000..1828e4e --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-solver-api/design.md @@ -0,0 +1,22 @@ +# 设计 + +技术合约不在本文重复定义。权威定义见父任务 `.trellis/tasks/08-26-direct-manipulation-road-editor/design.md` 的以下小节: + +- 「架构边界」— `resolveDirectEditConstraints` 在 `compileGeometry()` 之前的插入位置与数据流。 +- 「约束模型」— 6 个 kind 与各自的锚点、value、单位。 +- 「Handle manifest」— `HandleManifest` / `EditHandle` / `JunctionReserve` 字段。 +- 「坐标与单位分层」— 服务端在局部米制框架求解,传输一律 EPSG:4326 加方位角。 +- 「预览时序与延迟预算」— `previewSeq` 回显、`degraded` 标记、p95 ≤ 300ms。 +- 「编辑所有权」「不变量」— 两域划分与必须验证的几何不变量。 +- 「API 合约」— 5 个新端点、409 前置条件、既有端点的增量字段。 +- 「编译器几何版本变更」— `recheck` 策略。 + +## 本子任务的局部决定 + +- 求解器是纯函数,放在 `src/compile/` 下独立模块,不依赖 `fs` 与 `http`:预览、正式编译与 CLI 导出共用同一实现,这是"浏览器不维护第二套几何算法"的落点。 +- 米制换算全部走 `src/geometry/lane-geometry.js` 现有的 `metersAt()` / `haversineMeters()` / `projectedDistanceAlong()` / `lateralOffsetFrom()`。禁止新增换算函数;缺能力就扩展该模块。 +- handle manifest 与 diagnostics 是求解器的返回值,不是编译产物文件:预览不写盘,正式编译才落 GeoJSON。 +- `axisAzimuth` 由服务端算好,客户端只做投影。这样"哪个方向可拖"是语义决定而非 UI 猜测。 +- 重放匹配与求解分两个阶段:先把约束解析成 `exact` / `recheck` / `pending` / `conflicted` / `stale`,只有前两态进入求解。这样诊断与几何互不污染。 +- `POST /api/edit-preview` 复用现有请求体读取与 `sendJson` 工具,不引入新的 HTTP 框架。 +- 端点顺序:先 `GET /api/edit-state` 与 `POST /api/edit-preview`(只读/无写入,风险最低),再 `POST /api/edits` 与 revision 写入端点。 diff --git a/.trellis/tasks/08-26-direct-edit-solver-api/implement.jsonl b/.trellis/tasks/08-26-direct-edit-solver-api/implement.jsonl new file mode 100644 index 0000000..5dea06a --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-solver-api/implement.jsonl @@ -0,0 +1,7 @@ +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/design.md", "reason": "父任务权威合约:约束模型、handle manifest 字段、坐标分层、预览时序、不变量、API 合约"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "求解阶段插入点、图层依赖链顺序与约束不变量的完整来源"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "compileGeometry 现有输入输出、feature 回链字段与 v1 overrides 复用点"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/data-model-options.md", "reason": "分层求解规则与重放匹配策略的推导过程"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md", "reason": "junction reserve 所有权划分与 approach 约束优先级的依据"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "米制换算必须复用 src/geometry/lane-geometry.js,禁止新增第二套换算函数"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "新增 API 载荷与 manifest 字段跨编译器/服务端/客户端三层"} diff --git a/.trellis/tasks/08-26-direct-edit-solver-api/implement.md b/.trellis/tasks/08-26-direct-edit-solver-api/implement.md new file mode 100644 index 0000000..aa8e7c2 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-solver-api/implement.md @@ -0,0 +1,57 @@ +# 实施计划 + +对应父任务 `implement.md` 的第 4、5 步。每步一个提交,门禁不过就停。 + +前置:`direct-edit-documents` 已交付(v2 文档 schema、活动副本、area config 快照可读)。 + +## 1. 求解器骨架与恒等性 + +- 目标:插入 `resolveDirectEditConstraints` 阶段,且在无 v2 约束时是恒等变换。 +- 范围:`src/compile/` 新增纯函数模块;在 `compileGeometry()` 前接入;输出 editable profiles、junction plans、handle manifest、diagnostics 四件套的空实现。 +- 验证:`npm run test`,与 `direct-edit-documents` 建立的快照逐字节比对无差异。 +- 门禁:无 v2 约束时编译输出与快照完全一致。这条不过不许继续。快照必须是 `direct-edit-documents` 从当前 main 重新生成的那份,不是仓库原有的过期 `test/baseline/*.json`。 +- 回滚点:恒等变换,可安全单独 revert。 + +## 2. junction reserve 与 handle manifest + +- 目标:先产出"能拖什么、不能拖什么",为客户端解锁并行开发。 +- 范围:junction reserve 计算;6 个 kind 的 handle 生成,含 `position`、`axisAzimuth`、`value.min/max`、`affects`、`editable` 与 `disabledReason`。 +- 验证:`npm run test` 新增 fixture——reserve 内的道路 handle 一律 `editable: false` 且带原因;`affects` 覆盖道路面、步行带、车道线、标线与 connector;`axisAzimuth` 与道路法线/切线方向一致。 +- 门禁:manifest 字段与父任务 `design.md`「Handle manifest」逐字段一致,客户端可据此开工。 +- 回滚点:纯新增返回值,无调用方时 revert 无影响。 + +## 3. 六个 kind 的求解与不变量 + +- 目标:约束真正改变几何,且违规时给阻塞性诊断。 +- 范围:`road-edge-offset`、`road-sidewalk-width`、`road-lane-divider`、`junction-approach-width`、`junction-cutback`、`junction-corner-radius`;interval 默认范围推导(避开两端 reserve);`smoothstep` 过渡;`junction-approach-width` 优先级与两域边界连续。 +- 验证:`npm run test` 新增 fixture——每个 kind 一条重放用例;最小车道宽 2.4m、左右外缘不交叉、道路/路口连续、路口面不自交、connector 包含性各一条违规用例返回阻塞诊断。 +- 门禁:违规一律诊断,绝不静默修复或钳制到合法值。 +- 回滚点:按 kind 分批提交,可单独回退某个 kind。 + +## 4. 重放匹配与状态机 + +- 目标:OSM 重导入后约束的 5 态分类可靠。 +- 范围:精确匹配(`roadId` / `segmentId` / `nodeId`)优先;失败才用 `anchorSnapshot` 的 OSM node、距离与切线找候选;编译器版本不一致时全部标 `recheck`。 +- 验证:`npm run test` 新增 fixture——同一 OSM 全 `exact`;删掉某条道路后相关约束 `stale`;`boundaryIndex` 越界 `stale`;候选歧义或偏差超阈值 `conflicted`;版本变化全 `recheck` 且值未变。 +- 门禁:`pending` / `conflicted` / `stale` 一律不进入求解,不存在静默模糊应用的路径。 +- 回滚点:匹配模块独立,revert 后退回仅精确匹配。 + +## 5. 只读端点 + +- 目标:先上无写入风险的两个端点。 +- 范围:`GET /api/edit-state`、`POST /api/edit-preview`;`previewSeq` 回显、`degraded` 标记;复用现有请求体读取与 `sendJson`。 +- 验证:`npm run test` 新增 server integration——预览调用前后工作区目录内容与 mtime 不变;`previewSeq` 原样回显;预览与正式编译对同一约束集给出相同几何;超预算时 `degraded: true` 而非报错。 +- 门禁:预览零写入,有测试证明。 +- 回滚点:新增路由,revert 后其余端点不受影响。 + +## 6. 写入端点与并发保护 + +- 目标:保存、revision 与 rebase 落地,并挡住多标签页覆盖。 +- 范围:`POST /api/edits`(要求 `expectedDocumentVersion`)、`POST /api/revisions`、`POST /api/revisions/:id/rebase`;`/api/state`、`/api/session`、`/api/import` 增量字段。 +- 验证:`npm run test` 新增 server integration——版本不匹配返回 409 且不写入;原子写入中途失败不留半份文件;rebase 返回各 status 计数与明细;`/api/import` 成功后返回基线 revision id;既有 `/api/overrides`、`/api/compile`、`/api/state` 字段与行为不变。 +- 门禁:409 路径与原子失败路径都有测试覆盖;既有端点无回归。 +- 回滚点:写入端点独立,revert 后回到只读预览。 + +## 步骤依赖 + +1 → 2 → 3 硬顺序;4 可与 3 并行;5 依赖 2;6 依赖 5 与 `direct-edit-documents`。第 2 步交付后客户端子任务即可并行开工。 diff --git a/.trellis/tasks/08-26-direct-edit-solver-api/prd.md b/.trellis/tasks/08-26-direct-edit-solver-api/prd.md new file mode 100644 index 0000000..68feabb --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-solver-api/prd.md @@ -0,0 +1,42 @@ +# 约束求解器与编辑 API + +父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。需求来源与权威合约在父任务的 `prd.md` / `design.md`。 + +## 目标 + +把约束解析从几何生成中抽成纯函数边界,并用它同时支撑草稿预览与正式编译;对外提供预览、保存、revision 与 rebase 的服务端能力。本子任务不含任何 UI。 + +## 顺序依赖 + +前置:`direct-edit-documents`(需要 v2 文档 schema、`documentVersion`、活动副本与 area config 快照读取)。 +后继:`direct-edit-map-editor` 与 `direct-edit-junction-tools` 依赖本任务的 handle manifest 与预览 API。 + +## 范围 + +- `resolveDirectEditConstraints` 纯函数:输入基线模型 + v1 覆盖 + v2 约束,输出 editable profiles、junction plans、handle manifest、diagnostics。 +- 6 个约束 kind 的求解;interval 默认范围推导(避开两端 junction reserve);junction reserve 计算。 +- `junction-approach-width` 对道路 profile 的优先级,以及两域边界的连续过渡。 +- 约束重放匹配:精确匹配优先,失败才按 `anchorSnapshot` 找候选,产出 5 态 `ConstraintStatus`。 +- 端点:`GET /api/edit-state`、`POST /api/edit-preview`、`POST /api/edits`、`POST /api/revisions`、`POST /api/revisions/:id/rebase`。 +- `previewSeq` 回显、`degraded` 标记、`expectedDocumentVersion` 前置条件与 409。 +- `/api/state`、`/api/session`、`/api/import` 的增量字段。 + +## 不做 + +- 任何客户端改动、手柄渲染或拖拽交互。 +- cluster 的高级求解(只保证 cluster 不被当成多个普通路口处理)。 +- 多人协作的并发合并策略。 + +## 验收标准 + +- [ ] 求解是纯函数:无文件写入、无网络调用,可用单元 fixture 直接驱动。 +- [ ] 无 v2 约束时编译输出与当前 main 逐字节一致(baseline fixture 比对)。 +- [ ] 6 个 kind 各有重放 fixture;预览与正式编译对同一约束集给出相同几何。 +- [ ] 违反最小车道宽 2.4m、左右外缘交叉、路口面自交、connector 越界时返回阻塞性诊断而非静默修复。 +- [ ] `boundaryIndex` 越界转 `stale`;锚点缺失转 `stale`;候选歧义或偏差超阈值转 `conflicted`。 +- [ ] 编译器几何版本不一致时约束全部标 `recheck`,不自动改值也不自动失效。 +- [ ] `POST /api/edit-preview` 调用前后工作区目录内容与 mtime 不变。 +- [ ] `expectedDocumentVersion` 不匹配返回 409 且不写入;原子写入中途失败不留半份文件。 +- [ ] rebase 返回各 status 的计数与逐条明细。 +- [ ] 既有 `/api/overrides`、`/api/compile`、`/api/state` 的字段与行为不变。 +- [ ] `npm run format:check`、`npm run test`、`npm run test:client`、`npm run build` 全绿。 diff --git a/.trellis/tasks/08-26-direct-edit-solver-api/task.json b/.trellis/tasks/08-26-direct-edit-solver-api/task.json new file mode 100644 index 0000000..55dd2c0 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-solver-api/task.json @@ -0,0 +1,26 @@ +{ + "id": "direct-edit-solver-api", + "name": "direct-edit-solver-api", + "title": "约束求解器与编辑 API", + "description": "resolveDirectEditConstraints 纯函数边界,以及预览/保存/revision/rebase API", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-26", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-26-direct-manipulation-road-editor", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/check.jsonl b/.trellis/tasks/08-26-direct-manipulation-road-editor/check.jsonl new file mode 100644 index 0000000..eb15d39 --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/check.jsonl @@ -0,0 +1,5 @@ +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "检查跨层数据流是否一致:约束 kind、manifest 字段、API 载荷、客户端类型不得各写一套"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "检查是否出现第二套米制换算、第二套 ID 校验或重复的 overrides 应用逻辑"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "逐条核对约束不变量:最小车道宽、外缘不交叉、路口连续、connector 包含性"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md", "reason": "核对编辑所有权与会话边界未被越过:道路 handle 不得进入 junction reserve"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "核对 v1 overrides、feature 回链字段与既有 API 行为未被破坏"} diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/design.md b/.trellis/tasks/08-26-direct-manipulation-road-editor/design.md new file mode 100644 index 0000000..434076b --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/design.md @@ -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-/` 目录(`workbench/server.js` 用 `fs.mkdtempSync`),内含 `source.osm`、`native-road-overrides.json`、`native-traffic-signals.json`、`outputs/`。新结构在该目录内扩展,不改动既有文件语义: + +```text +workbench-data/import-/ + source.osm 保留,v1 路径兼容 + native-road-overrides.json 保留,仍是 v1 覆盖的权威文件 + native-traffic-signals.json 保留 + outputs/ 保留 + osm/.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` 的手测清单覆盖。 + + + + diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/implement.jsonl b/.trellis/tasks/08-26-direct-manipulation-road-editor/implement.jsonl new file mode 100644 index 0000000..a2a7e6c --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/implement.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "本特性横跨 compiler / server / client 三层,新增 JSONL 文档、API 载荷与配置字段,正是该指南的触发条件"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "米制换算必须复用 src/geometry/lane-geometry.js,v1 overrides 与 ID 校验必须复用而非另建一套"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md", "reason": "现有可复用锚点、feature 回链字段与当前缺口清单"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md", "reason": "求解阶段的插入点、依赖链顺序与约束不变量来源"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/canvas-integration.md", "reason": "输入 adapter 的候选、ol-ext 探针退出条件与回退优先级"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/revisions.md", "reason": "revision manifest 字段与重导入冲突体验的来源"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md", "reason": "JunctionTools 的会话边界、预览/应用/保存状态与路口所有权划分"} +{"file": ".trellis/tasks/08-26-direct-manipulation-road-editor/research/data-model-options.md", "reason": "三种数据模型的比较背景与选择理由;kind 枚举以 design.md 为准"} diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/implement.md b/.trellis/tasks/08-26-direct-manipulation-road-editor/implement.md new file mode 100644 index 0000000..06783dd --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/implement.md @@ -0,0 +1,107 @@ +# 实施计划 + +每步是一个独立提交,带自己的验证命令、门禁和回滚点。门禁不过就停在该步,不带着已知缺陷进入下一步。 + +约束与合约的权威定义在 `design.md`;本文只管执行顺序与验证。 + +## 全局回滚策略 + +- 任务分支上一步一提交,回滚等于 `git revert` 该提交,不做跨步大回滚。 +- 客户端编辑能力全程挂在 `directEdit` 开关后,默认关闭,直到第 8 步手测清单通过才默认开启。开关关闭时工作台行为与当前 main 完全一致。 +- 服务端新增端点与新文件是纯增量:`active/` 与 `revisions/` 不存在时,旧代码路径照常工作。任何一步回滚都不会让既有 import 目录无法打开。 +- v1 `native-road-overrides.json` 始终保持权威且格式不变,任何步骤都不迁移它。 + +## 0. 前置:依赖与测试基础设施 + +- 目标:先把验证能力建好,避免后续步骤"写完没法测"。 +- 范围:`package.json` 增加 `vitest`(固定版本)与 `test:client:unit` 脚本;建立客户端纯逻辑测试目录。不碰任何产品代码。 +- 验证:`npm run test:client:unit`(空套件通过)、`npm run format:check`、`npm run test`。 +- 门禁:新脚本可跑通且不影响现有 `test` / `test:client` / `build`。 +- 回滚点:仅 `package.json` 与测试目录,revert 无副作用。 + +## 1. `ol-ext` 限时探针 + +- 目标:只回答一个问题——能否复用通用 handle 的命中、pointer 生命周期与视觉反馈。 +- 范围:隔离探针,不修改生产基线图层,不进依赖清单直到通过。 +- 验证:手动跑探针页;确认 OL `Map` 未重建、proxy 拖拽稳定、基线 source 未被写入、事件能转成 draft 值。 +- 门禁:三条同时成立才把 `ol-ext` 固定为依赖。超过一个工作日或任一条不成立,立刻停止排障。 +- 回滚点:删除探针目录,改用原生 OL `Snap` + 小型 `PointerInteraction` adapter。`HandleManifest -> RoadEditOperation -> RoadConstraint -> preview solver` 合约不变,因此本步失败最多换输入层,不动 schema、求解器或已保存编辑。 + +## 2. v2 文档、活动副本与 revision 基础 + +- 目标:落地 `native-road-edits/v2`、operations、`anchorSnapshot` 与 5 态重放状态机。 +- 范围:新增文档读写、`active/` 与 `revisions/` 布局、内容寻址 OSM 副本、`documentVersion`、命名检查点与恢复读取。惰性迁移既有 import 目录,不移动既有文件。v1 overrides 完全不动。 +- 验证:`npm run test`(新增 fixture:文档往返、`documentVersion` 递增、惰性迁移在只有旧文件的目录上生成 `rev-0001`、内容相同的 OSM 不产生第二份副本)。 +- 门禁:既有 `workbench-data/import-*` 目录在新代码下可正常打开,且未被改写;schema 校验拒绝越界 `boundaryIndex` 与非法 station。 +- 回滚点:revert 本步后 `active/` 与 `revisions/` 只是残留目录,旧代码忽略它们照常运行。 + +## 3. area config 快照 + +- 目标:切断编译对外部区域配置文件的运行期依赖,让 revision 真正可复现。 +- 范围:活动副本与每个 revision 各写一份 `area-config.snapshot.json`;编译与预览改读快照;`/api/junction-clusters` 改写快照并在响应里返回可复制片段。 +- 验证:`npm run test` 新增 fixture——冻结 revision 后修改外部 area config,重新编译该 revision 结果不变;快照缺失时给出明确错误而非静默用外部文件。 +- 门禁:现有 `/api/junction-clusters` 的用户可见行为(返回可复制片段)不退化。 +- 回滚点:revert 后编译回到读外部配置文件;已写的快照文件被忽略,不影响 v1 链路。 + +## 4. 约束求解边界 + +- 目标:在 `compileGeometry()` 前插入纯函数 `resolveDirectEditConstraints`,输出 editable profiles、junction plans、handle manifest 与 diagnostics。 +- 范围:实现 6 个 kind 的求解;interval 默认范围推导(避开两端 reserve);junction reserve 计算;`junction-approach-width` 对道路 profile 的优先级与边界连续过渡。米制换算复用 `src/geometry/lane-geometry.js`,不新增第二套。 +- 验证:`npm run test` 新增 fixture——每个 kind 的重放、最小车道宽 2.4m、左右外缘不交叉、道路/路口连续、路口面不自交、connector 包含性、`boundaryIndex` 越界转 `stale`、锚点缺失/歧义分别转 `stale`/`conflicted`。 +- 门禁:求解是纯函数,无文件写入、无网络;`npm run test` 全绿且既有 baseline 输出在无 v2 约束时逐字节不变。 +- 回滚点:无 v2 约束时该步是恒等变换,因此 revert 前后编译输出一致,可安全单独回退。 + +## 5. 预览与编辑 API + +- 目标:草稿预览与持久化分离,预览绝不写文件。 +- 范围:`GET /api/edit-state`、`POST /api/edit-preview`、`POST /api/edits`、`POST /api/revisions`、`POST /api/revisions/:id/rebase`;`previewSeq` 回显;`degraded` 标记;`expectedDocumentVersion` 前置条件与 409;`/api/state`、`/api/session`、`/api/import` 的增量字段。 +- 验证:`npm run test` 新增 server integration——预览调用前后目录 mtime 与内容不变;`expectedDocumentVersion` 不匹配返回 409 且不写入;原子写入在中途失败时不留半份文件;rebase 返回各 status 计数;预览与正式编译对同一约束集给出相同几何。 +- 门禁:预览零写入;409 路径有测试覆盖;现有 `/api/overrides`、`/api/compile`、`/api/state` 的既有字段与行为不变。 +- 回滚点:新端点是增量,revert 后客户端开关已关闭,工作台回到 v1 行为。 + +## 6. 主地图 Road editor + +- 目标:外缘、步行带、车道分隔与区间范围可拖,禁入 junction reserve。 +- 范围:selection → handle manifest;handles / ghost / preview 三个独立 source;`EditSession` 命令栈与 undo/redo;80ms 防抖与 `pointerup` 最终请求;`previewSeq` 乱序丢弃与 `AbortController` 取消;无效草稿反馈;应用/保存/取消。全部挂在 `directEdit` 开关后。 +- 验证:`npm run test:client`、`npm run test:client:unit`(命令栈与 undo/redo、乱序响应丢弃、handle 事件到约束值的投影、米制换算的纬度正确性)、`npm run build`。 +- 门禁:单元测试覆盖上述四类纯逻辑;拖拽期间 `Map` 未重建且只有受影响 source 被替换(手测清单第 1-3 项)。 +- 回滚点:关掉 `directEdit` 开关即可现场止损,无需回滚代码;必要时 revert 本步。 + +## 7. 最小 JunctionTools + +- 目标:单一普通 node 路口的进口宽度、cutback、单角部圆角,闭合预览/应用/取消。 +- 范围:单 `JunctionRef` 会话的路由与返回主地图的上下文;session draft;完整局部预览;应用合并到活动副本;取消回到进入前状态;切换路口前强制应用或取消。不做 cluster 高级编辑、不做多路口联动。 +- 验证:`npm run test:client:unit`(会话状态机:draft → 应用 → 主工作区未保存约束;取消后状态复原;未处理草稿时切换被拒绝)、`npm run test`(局部求解与全量编译一致)、`npm run build`。 +- 门禁:预览包含全部受影响派生对象(道路面、路口面、步行带、车道中心线、connector、停止线、斑马线、标线);拟合失败时返回最后有效几何加结构化诊断,不把非法图形显示成已应用。 +- 回滚点:路由与开关独立,revert 不影响第 6 步的主地图编辑能力。 + +## 8. 全量验证与开关默认开启 + +- 目标:跑完交付级验收标准,确认可默认开启 `directEdit`。 +- 范围:补齐 `prd.md`「验收标准(交付阶段)」逐条证据;开关默认值改为开启。 +- 验证:`npm run format:check`、`npm run test`、`npm run test:client`、`npm run test:client:unit`、`npm run build`,加下方手测清单。 +- 门禁:交付级验收标准全部勾选;任一条不过则开关保持关闭,任务不进入 Phase 3。 +- 回滚点:只改开关默认值,回滚成本为一行。 + +## 手测清单 + +自动化测试覆盖不到的地图行为,每次进入第 8 步都要跑一遍: + +1. 选中道路 → 出现手柄,浏览器 devtools 中 `Map` 实例未变(拖拽前后同一对象引用)。 +2. 拖动外缘 → 只有道路面、步行带、车道线、标线、connector 的 source 被替换,底图与未受影响图层无重绘闪烁。 +3. 连续快速拖拽后松手 → 最终几何与松手位置一致,无回跳(验证乱序丢弃与最终请求)。 +4. 拖到违反最小车道宽 → 出现阻塞诊断,地图停在最后一个有效预览。 +5. 手柄落在 junction reserve 内 → 不可拖动并提示进入 `JunctionTools`。 +6. 进入 `JunctionTools` 调三项 → 预览完整;应用 → 返回主地图仍显示同一几何;取消 → 状态复原。 +7. 未处理草稿时尝试切换相邻路口 → 被拒绝并提示先应用或取消。 +8. 保存 → 重新编译 → 几何不变,约束状态 `exact`。 +9. 重新导入同一份 OSM → 约束全部 `exact`;导入删掉某条道路的 OSM → 相关约束 `stale`,旧 revision 仍可打开复现原结果。 +10. 两个标签页同时保存 → 后者 409 提示,前者结果保留。 +11. 关闭 `directEdit` 开关 → 工作台行为与当前 main 一致。 + +## 步骤依赖 + +0 → 1 可并行于 2;2 → 3 → 4 → 5 是硬顺序;6 依赖 1、4、5;7 依赖 6;8 依赖全部。第 1 步失败不阻塞 2-5,只改变第 6 步的输入 adapter 选择。 + + + diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/prd.md b/.trellis/tasks/08-26-direct-manipulation-road-editor/prd.md new file mode 100644 index 0000000..3f25895 --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/prd.md @@ -0,0 +1,107 @@ +# 直接操纵道路编辑工作台 + +## 目标 + +让道路编译工作台支持类似 Drawtonomy 的直接操纵:用户能在地图上选择生成的道路面并通过有语义的控制柄调整细节,同时保留 OSM 作为输入来源、修改可解释且可审计,并能在重新编译或重新导入后可靠地重放。 + +## 已确认的事实 + +- `src/compile/native-road.js` 从 OSM 生成方向道路、车道、道路面、步行带、路口面、标线和连接路径;这些 GeoJSON 是派生输出,不是持久化编辑源。 +- 现有 `native-road-overrides/v1` 已支持道路宽度/车道数/步行带开关、路口和车道连接、以及标线样式,且以稳定的 OSM 派生语义 ID 定位目标,并在 OSM 改动后识别失效项。 +- 编译器使用 staging 目录原子发布输出;工作台已经具有单例 OpenLayers 地图、命令式图层 source 更新、选择高亮和暂存/保存/重新编译链路。 +- Drawtonomy 的公开 SDK 表明其道路由共享 `point`、`linestring` 和引用左右边界的 `lane` 构成;连接关系和导出几何从该对象图派生。其编辑器核心(手势状态机、选择、历史)不在公开仓库中,不能作为源码依赖。 + +## 产品要求 + +1. 编辑目标必须是道路语义对象,而不是直接保存最终 GeoJSON 多边形。 +2. 拖拽时必须即时显示预览,并清楚呈现将被同步影响的道路面、车道、标线和路口对象。 +3. 每次持久化修改都必须可解释:谁在何处、以什么规则、相对什么输入锚点施加了何种约束。 +4. OSM 重新导入或编译器升级后,修改应自动重放、明确报告冲突/失效,绝不能静默改写到错误道路。 +5. 用户必须可以撤销/重做未保存编辑,并能查看、禁用或删除已保存编辑。 +6. 新架构应复用现有道路参数 overrides、ID 校验、编译和图层更新能力,而不是并行维护第二套地图数据。 +7. OSM 道路中心线和拓扑保持只读;直接操纵首期仅编辑由它派生的横断面与路口细节。 +8. 首期必须同时验证道路横断面与路口细节的联合编辑:道路外缘、步行带、车道分隔,以及路口进口、cutback 与转弯角部都属于首批能力。 +9. 道路横断面拖拽默认生成以拖拽站点为中心的局部区间,并在区间边界平滑过渡回基线;用户可以用范围控制柄修改影响区间。 +10. 拖拽时浏览器立即更新控制柄、辅助线与半透明 ghost;在短防抖后调用服务端权威预览,服务端使用与正式编译相同的约束求解器返回受影响派生图层。浏览器不维护第二套道路几何算法。 +11. 工作台必须支持可复现的存盘 revision:冻结一次编译所需的 OSM 输入、区域配置、已有 overrides、直接编辑约束、信号数据和编译身份;重新导入 OSM 创建新 revision,不覆盖旧 revision。 +12. 导入 OSM 自动创建基线 revision;用户显式保存命名检查点时创建不可变 revision;普通保存和预览只更新活动副本。 +13. 路口点击编辑进入专用 `JunctionTools` 工作区,而非在主地图叠加完整路口控制面板;该工作区复用活动 revision 与直接编辑约束,并在操作后尽快重新求解生成道路拓扑和相关派生图层。 +14. `JunctionTools` 的草稿从首次拖拽起即显示即时 ghost 和服务端权威几何预览;“应用到工作区”仅合并有效草稿到主工作区,“保存”才持久化,“取消”丢弃本次会话草稿。 +15. 一次 `JunctionTools` 会话只编辑一个 node 或 cluster;切换相邻路口前必须应用或取消当前草稿。 +16. 主地图道路区间编辑仅拥有两个路口之间的内部区间;`JunctionTools` 独占路口保留区内的进口、cutback 与角部约束。求解器保证两侧连续,并以显式 junction approach 约束优先。 +17. 首个 `JunctionTools` 交付只验证单一普通路口的进口宽度、cutback 和单个角部圆角;必须展示完整拟合预览并能应用回主工作区。 +18. 约束 kind 枚举必须唯一且与首期范围一一对应;车道分隔必须有自己的约束 kind,不得借用外缘偏移表达。 +19. 预览必须有明确时序保证:乱序响应不得覆盖较新预览;拖拽防抖与 `pointerup` 最终请求分离;超出延迟预算时降级提示而不是几何闪烁。 +20. 持久化约束只使用米与归一化 station;坐标与单位换算的分层职责必须固定,客户端不得用投影坐标差充当米。 +21. 区域配置(含 `nativeRoad.junctionTemplates`)必须随活动副本与 revision 冻结为快照,编译与预览只读快照;否则 revision 不可复现。 +22. 同机多标签页并发保存必须被显式挡住(版本前置条件加冲突提示),不得静默互相覆盖。多人协作仍延后。 +23. revision 不自动删除;OSM 副本按内容寻址避免重复;派生产物缓存可清理且清理不影响可复现性。 +24. 编译器几何版本变化时,约束既不自动失效也不自动改值,必须经用户显式确认重放。 +25. 编辑能力只进 React 工作台;遗留 vanilla 客户端不同步、不在本任务内删除。 +26. 客户端纯逻辑必须可自动化测试;地图交互行为以固化在 `implement.md` 的手测清单覆盖。 + +## 需要完成的研究与设计 + +- 梳理当前模型、输出 feature 属性和现有 overrides 可直接复用的锚点。 +- 明确 Drawtonomy 的公开对象图、共享几何和派生原则,以及它与 OSM 编译流程不相同的边界。 +- 比较参数反推、语义几何约束、局部补丁几何三种数据模型,并给出持久化、重放、冲突、撤销和迁移策略。 +- 在确认编辑边界后,形成交互模型、编译边界、API 合约、版本化 schema 和分阶段实施计划。 +- 在实现前验证 `ol-ext` 的 Transform interaction 能否仅操作临时代理 feature,并与现有 `ol@10.10.0`、React 生命周期和局部预览稳定协作;验证结果决定是否纳入正式依赖。 +- `ol-ext` 验证必须是有时间上限、可独立删除的探针;失败仅替换输入 adapter,不得改变约束文档、求解器或编译架构。 +- `ol-ext` 探针限时一个工作日;必须同时满足“无地图重建、无基线 source 写入、拖拽稳定”,否则停止排障并采用 OpenLayers 原生 proxy + 小型语义 adapter。 + +## 任务地图 + +本任务是父任务:持有需求集、`design.md` 权威合约、跨子任务验收标准与最终集成验证。实现落在四个子任务,顺序依赖写在各自 `prd.md` / `implement.md`,不由树结构隐含。 + +| 子任务 | 交付物 | 对应父 `implement.md` 步骤 | 前置 | +| --- | --- | --- | --- | +| `08-26-direct-edit-documents` | v2 文档、活动副本、revision、area config 快照、测试基础设施 | 0、2、3 | 无 | +| `08-26-direct-edit-solver-api` | `resolveDirectEditConstraints` 与预览/保存/revision/rebase API | 4、5 | documents | +| `08-26-direct-edit-map-editor` | `ol-ext` 探针与主地图道路区间编辑 | 1、6 | solver-api 第 2 步(manifest) | +| `08-26-direct-edit-junction-tools` | 单一普通路口的进口、cutback、单角部圆角 | 7 | solver-api、map-editor | + +父任务自留第 8 步:跑完交付级验收标准并把 `directEdit` 开关默认开启。 + +## 延后项 + +- 不在当前前置架构阶段设计或实现高级复合路口模板编排、多个路口的联合会话、控制设施逐个手工布置、协作合并或全面的拓扑创作工具。 + +## 首个交付范围 + +- 版本化直接编辑文档、活动工作副本和命名 revision 检查点。 +- 限时的 `ol-ext` 代理 feature 验证及原生 OpenLayers adapter 回退。 +- 服务端权威预览 API 与客户端即时 ghost。 +- 主地图的道路内部区间:外缘、步行带、车道分隔和范围控制。 +- 最小 `JunctionTools`:单一普通路口的进口、cutback、单角部圆角、预览/应用/取消。 +- 约束重放的精确匹配、待确认和失效状态;旧 revision 永不被重导入覆盖。 + +## 验收标准(规划阶段) + +- [x] 已提供当前能力与可复用边界的证据清单。 +- [x] 已区分 Drawtonomy 的公开事实、可迁移原则和不可验证的编辑器内部实现。 +- [x] 已确定持久化编辑数据不是最终 GeoJSON,并有版本化、可重放、可失效诊断的数据方案。 +- [x] 已明确拖拽预览、应用、保存、重新编译、撤销/重做、OSM 重导入和 revision 检查点的行为。 +- [x] 已定义 `ol-ext` 限时探针、原生 OpenLayers 回退与不改变数据合同的止损规则。 +- [x] 已定义首个 Road editor 与最小 JunctionTools 的范围、所有权和延后项。 +- [x] 约束 kind 枚举唯一,且覆盖首期全部 6 项能力(含车道分隔)。 +- [x] handle manifest、坐标分层、预览时序、area config 快照、单写者保护、留存策略均已在 `design.md` 定义。 +- [x] 已明确遗留客户端边界与客户端测试基础设施的决定。 +- [x] 已形成 `design.md` 和 `implement.md`,供用户审阅后再开始实现。 + +## 验收标准(交付阶段) + +每条都必须可执行验证,不接受"看起来对了"。 + +- [ ] 拖动道路外缘 → 保存 → 重新编译,道路面、步行带、车道线、标线与 connector 一致更新,约束状态为 `exact`。 +- [ ] 车道分隔手柄可独立调整某条分隔线,且不等价于外缘偏移的副作用。 +- [ ] 落在 junction reserve 内的道路手柄不可拖动,并给出引导进入 `JunctionTools` 的原因。 +- [ ] `JunctionTools` 单路口会话可调进口宽度、cutback 与一个角部圆角,预览包含全部受影响派生对象;应用后主地图继续显示同一预览,取消后回到进入前状态。 +- [ ] 违反最小车道宽 2.4m 或造成路口面自交的草稿返回阻塞性诊断,且不替换最后一个有效预览。 +- [ ] 快速连续拖拽时乱序预览响应不会覆盖较新结果(可通过注入延迟的测试复现)。 +- [ ] 同一 OSM 重新导入后,约束按 `exact` / `pending` / `conflicted` / `stale` 分类报告;旧 revision 仍可打开并复现原结果。 +- [ ] 修改 area config 后,旧 revision 的编译结果不变(证明快照生效)。 +- [ ] 两个标签页并发保存时,后者收到 409 且不覆盖前者。 +- [ ] 未保存编辑可撤销/重做;已保存编辑的撤销以反向操作或禁用约束体现,历史不被重写。 +- [ ] 拖拽与选择过程中 OpenLayers `Map` 未被重建,只有受影响 source 被替换。 +- [ ] `npm run format:check`、`npm run test`、`npm run test:client`、新增客户端单元测试与 `npm run build` 全绿。 diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/research/canvas-integration.md b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/canvas-integration.md new file mode 100644 index 0000000..177cb24 --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/canvas-integration.md @@ -0,0 +1,108 @@ +# Canvas 与控制柄工具方案 + +## OpenLayers 能力确认 + +当前项目使用 `ol@10.10.0`。OpenLayers `VectorLayer` 默认由 Canvas renderer 绘制,当前工作台正是这种模式。类型声明可确认: + +- `ol/interaction/Modify` 支持对 source/feature collection 的顶点修改,带 `modifystart` / `modifyend`、自定义 vertex style、pixel tolerance 与 hit detection。 +- `ol/interaction/Snap` 支持顶点、边、交点吸附,且会改写交互事件的 coordinate/pixel,供其他 pointer interaction 使用。 +- `ol/interaction/Translate` 支持 feature 集合的拖动和 start/move/end 事件。 + +因此,OL 具备 Canvas 渲染、命中测试、投影换算、视口同步与低层 pointer 交互所需的基础能力。它没有白板编辑器的命令历史、语义控制柄、约束求解、工具状态或多对象选择模型。 + +## 不使用原生 `Modify` 直接编辑道路面 + +`Modify` 会直接改变传入 feature 的 geometry 坐标,也允许插入/删除顶点。这适合通用 GIS 几何编辑,但不适合本项目:道路面和路口面是编译产物,顶点没有稳定的编辑语义,直接修改会使车道、标线和 connector 脱节。 + +它可被借鉴的只是事件生命周期、Canvas 命中与样式机制;道路源图层必须保持只读。 + +## 建议架构 + +```text +OpenLayers Map / VectorLayer (Canvas) + - 基线与预览 GeoJSON 图层:只读、由编译器/preview solver 提供 + - editHandles VectorLayer:Point/LineString feature,Canvas 画柄与影响区间 + - EditPointerInteraction:命中 handle -> 投影拖拽方向 -> 更新 draft command + - Snap:只对批准的语义锚点/网格 source 生效 + | + v +React EditSession(selection、draft、undo/redo) + | + v +constraint preview solver -> 仅更新受影响 source +``` + +控制柄是独立的、可丢弃的 UI feature;它的 properties 只携带 `handleId`,并通过 manifest 反查 semantic anchor。任何鼠标移动都先被投影为结构化约束值,再重算预览,绝不把 handle 的地理坐标直接写为道路 polygon 坐标。 + +## 引入独立 Canvas 白板库的判断 + +将 tldraw、Fabric、Konva 等作为覆盖层或替换 OL,均会引入第二个 viewport、平移/缩放手势、坐标系与 hit-test 系统。与地理坐标、OL hit detection、原有图层开关和地图导航同步的成本很高,且不能自动提供道路领域的约束模型。 + +除非“Quickdraw”是一个能在既有地图 Canvas 内以 OpenLayers coordinate/pointer API 运行的明确库,否则首选是继续把 OL 作为唯一 Canvas 和视口,使用它成熟的 interaction primitives,加一个小而领域化的 `EditPointerInteraction`。这不是手写画布;渲染、坐标、事件和吸附都由 OL 提供,新增代码只负责道路语义。 + +## 成熟的 OpenLayers 扩展:ol-ext + +`ol-ext` 是当前最匹配的成熟扩展候选:npm 最新版为 `4.0.38`(2026-02-23),BSD-3-Clause,仓库仍持续维护。其 `interaction/Transform` 明确提供: + +- 单独的 Canvas overlay layer 与可定制的 transform handles; +- 与 `Select` 交互同步选择; +- feature translate / scale / stretch / rotate; +- `translatestart`、`translating`、`translateend`、`scalestart`、`scaling`、`scaleend` 等生命周期事件; +- 通过 `PointerInteraction` 实现,因此共享 OpenLayers 的地图坐标、事件分发与视口。 + +这能承担通用控制柄的视觉和 pointer 生命周期,消除自行实现 hover、命中、capture、拖拽状态机和控制柄绘制的大部分工作。 + +### 不能委托给 ol-ext 的部分 + +`Transform` 的最终行为仍是缩放、旋转或平移传入 feature 几何。其控制柄是矩形 bounding box,而道路编辑需要沿道路法线的 edge offset、沿中心线的范围控制和路口特定的 cutback/corner radius;这些语义并非该扩展的能力。 + +推荐将 ol-ext 只绑定到**短生命周期 proxy feature**: + +```text +语义 handle manifest -> proxy feature / ol-ext Transform + -> transform event -> 约束值(法向偏移、station interval、cutback、radius) + -> preview solver -> 新预览几何与新的 proxy +``` + +真实编译产物图层不可传给 `Transform`。对于不能表达为标准 translate/scale 的角部圆角和区间范围控制,仍需一个很小的领域 adapter;但它只做“事件到约束”的投影,不维护自己的 Canvas 或通用手势系统。 + +## 建议的验证闸门 + +在正式实现前完成一个隔离技术验证:将 `ol-ext` 的 Transform 绑定到单道路的临时 proxy,确认它能在当前 `ol@10.10.0`、React map 生命周期下稳定工作,并验证一次拖拽只更新 proxy/preview source、不会改写基线 source、不会重建地图。通过后再将它固定为依赖;不通过则保留 OpenLayers 原生 interaction 方案,避免在主分支承诺未经验证的扩展。 + +## 有限探针与回退策略 + +`ol-ext` 不是架构前提。探针只允许解决一个问题:复用通用 handle 的命中、pointer lifecycle 和视觉反馈。它有明确的时间上限和退出条件: + +| 结果 | 动作 | +| --- | --- | +| 可在临时 proxy 上平稳拖拽,且不写基线 source、不重建 Map | 作为标准 transform 代理的可选依赖 | +| 与 `ol@10.10.0` 或 React 生命周期不兼容 | 删除探针,不迁移现有 MapCanvas;走原生 OL 方案 | +| 能运行但矩形 scale/rotate 模型妨碍道路法线/路口约束 | 不把它用于道路手柄;仅保留可复用部分或删除 | +| 探针超过预设时间仍无法达到以上条件 | 停止排障,按回退方案推进 | + +回退方案按优先顺序: + +1. **OpenLayers 原生 proxy + 小型 `PointerInteraction` adapter**:Point/LineString proxy 仍由 OL Canvas 绘制和命中,使用 `Snap` 处理吸附;adapter 只处理已命中的少量语义 handle 到 constraint 的投影。它不重写渲染、相机、图层或通用选择,工作量受限。 +2. **DOM handle overlay + 成熟手势库**:仅在当前选中道路/路口上放置少数固定像素的 React/HTML controls,用成熟拖拽手势库处理 pointer capture;每个地图 postrender 将它们用 `map.getPixelFromCoordinate()` 对齐。地图仍是唯一视口,未命中 handle 的事件继续交给 OL。适合复杂的范围、数值标签和路口控制,但不是 Canvas 风格。 +3. **不采用的方向**:迁移到 MapLibre/Leaflet 仅为获取绘制插件,或把 Quickdraw 叠到 OL 上。这会更换地图/视口基础设施,成本远高于有限 adapter,且不能解决道路语义。 + +无论哪个输入 adapter 运行,`HandleManifest -> RoadEditOperation -> RoadConstraint -> preview solver` 的数据合同保持不变。因此探针失败最多替换输入层,不会推翻 schema、求解器或已保存编辑。 + +## Drawtonomy 的关系 + +公开 checkout 未包含 `Canvas.tsx`,也未在 package manifests 或 lockfile 中暴露 `tldraw`、Konva、Fabric、Excalidraw 或名为 Quickdraw 的依赖。因此不能从该源码确认其编辑器具体使用了哪个成熟 Canvas 库,只能借鉴其公开的对象图原则。 + +## Quickdraw 核查结论 + +检查 `/tmp/quickdraw` 后,确认它是 MIT 许可、零运行时依赖的完整无限白板 SDK,而不是地图编辑器控制柄库: + +- `packages/core/src/editor.js` 在传入 container 内创建自己的 scene canvas 和 overlay canvas,并实现独立 camera(`x/y/z`)、平移、缩放、pinch、pointer capture、hit test、selection 和 resize/rotate/arrow handles。 +- `packages/core/src/store.js` 使用不可变 record、transaction、diff、batch undo/redo;同一手势的连续更新会合并为一个 history entry。 +- 内建形状和工具是封闭集合(笔、箭头、几何形状、文字等),公开资料中没有可把 OpenLayers feature 注册为原生 shape/handle 或让它共享外部 map camera 的扩展接口。 + +### 结论 + +不将 Quickdraw 作为覆盖层或替代 OpenLayers:二者都会处理 pointer、wheel、pinch、屏幕到世界坐标转换和 camera,接入后要持续同步两套视口,且会破坏当前 OL 地图导航和图层命中。也不将 Quickdraw 的 flat drawing document 作为道路约束的存储格式。 + +可直接借鉴的成熟实现原则是:固定像素尺寸的控制柄、overlay 与场景分离、pointer capture、一个 gesture 对应一个 transaction、不可变 diff 的 undo/redo、以及局部渲染。项目自身的 `EditSession` 应采用相同语义,但以 `RoadEditOperation` / `RoadConstraint` 为数据而非 Quickdraw shape。 diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md new file mode 100644 index 0000000..a0dd064 --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/current-system.md @@ -0,0 +1,30 @@ +# 当前系统能力地图 + +## 编译链路 + +`src/compile/compiler.js:16` 读取 OSM 和 `native-road-overrides.json`,调用 `compileRoadModel()` 与 `compileGeometry()`,写入 staging 目录后原子替换产物目录。生成 GeoJSON 因此是可丢弃、可重建的派生层。 + +`src/compile/native-road.js:79` 将 OSM way 按共享节点拆分为方向道路;每条道路有稳定的 `road:way/[:segment/]:` ID、`segmentId`、OSM 节点端点和中心线。道路面/步行带/车道线/中心线/路口/连接线均由这个模型计算。 + +## 可直接复用 + +| 现有能力 | 直接操纵中的职责 | +| --- | --- | +| 道路、端点、车道与 segment ID | 约束的语义锚点和 OSM 重导入后的匹配基础 | +| `native-road-overrides/v1` 校验与 stale diagnostics | 新 schema 的版本化校验、失效检测和编辑列表 | +| `applyRoadOverrides()` | 把可反推的拖拽收敛为既有 `widthMeters`、步行带等参数 | +| `compileGeometry()` | 约束生效后的权威重算边界 | +| feature `native_id`、`road_id`、`segment_id`、`osm_node_id` | 渲染 feature 到语义编辑目标的回链 | +| OpenLayers 单实例 + source registry | 手柄、预览和选中态可作为独立 source/layer 增量更新,避免重建容器 | +| 暂存/保存/重新编译 API | 命令历史与持久化操作的基础链路 | + +## 当前缺口 + +- 没有描述“一个渲染边界/顶点对应哪个语义控制点”的 handle manifest。 +- 现有 road override 只能整体宽度/车道数,不表达沿道路位置变化、边缘偏移、局部过渡或路口角部约束。 +- 没有操作日志、撤销/重做、预览求解器或冲突 rebase 状态。 +- 路口模板在 area 配置中,不在 overrides 文件;直接操纵要明确哪些路口细节可落到 area 模板、哪些可成为用户覆盖项。 + +## 约束 + +不要将道路面 polygon、标线 polygon 或任意 OpenLayers `Feature` 坐标作为主要持久化编辑内容。它们没有足够的拓扑语义,且在 OSM 拆段、路口重算或编译器升级后很难可靠重放。 diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/research/data-model-options.md b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/data-model-options.md new file mode 100644 index 0000000..765ca97 --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/data-model-options.md @@ -0,0 +1,81 @@ +# 可编辑数据模型的比较 + +> 本文是方案比较的探索记录。约束模型、kind 枚举、锚点类型与文档 schema 的权威定义在 `design.md`;如有冲突以 `design.md` 为准。 + +## 共同不变量 + +- OSM 原文与其解析出的道路模型是基线,不由拖拽直接覆盖。 +- 所有持久化修改都必须含版本、稳定锚点、创建时基线指纹、参数、状态和解释信息。 +- 每个输出 feature 都必须能给出生成它的输入语义 ID 与适用约束,供 UI 选择和诊断使用。 +- 拖拽过程可使用临时求解结果;只有显式保存才写入可重放命令。 + +## 方案比较 + +| 方案 | 持久化内容 | 优点 | 根本限制 | +| --- | --- | --- | --- | +| 参数反推 | `widthMeters`、lane count、步行带宽度等 | 可最大复用 v1,重编译最稳 | 不能表达沿程局部收放、独立边缘或路口角部 | +| 语义几何约束 | 对道路横断面、边缘、过渡、路口的约束 | 兼顾直接操纵与 OSM 重放;适合本项目 | 需要 solver、优先级和冲突诊断 | +| 局部 patch 几何 | 渲染 polygon 的顶点/边偏移 | 表达最自由 | 极难在 OSM 或生成算法变化后重定位,拓扑易破坏 | + +## 建议方向:版本化的约束文档 + +不是将所有编辑统一存为一个巨型 mesh,而是将它们存为带锚点的声明式约束,并保留“由哪个交互生成”的命令记录。 + +```ts +interface RoadEditDocument { + schema: 'native-road-edits/v2' + base: { + osmSha256: string + compilerGeometryVersion: string + createdAt: string + } + constraints: RoadConstraint[] + operations: RoadEditOperation[] +} + +interface RoadConstraint { + id: string + kind: RoadConstraintKind // 6 个取值见 design.md「约束模型」 + anchor: SemanticAnchor + anchorSnapshot: AnchorSnapshot + value: unknown + enabled: boolean + status: ConstraintStatus // exact | recheck | pending | conflicted | stale + provenance: { operationId: string; createdAt: string; author?: string } +} + +type SemanticAnchor = + | { type: 'road-station'; roadId: string; station: number; side?: 'left' | 'right' } + | { type: 'road-interval'; roadId: string; startStation: number; endStation: number; side?: 'left' | 'right' } + | { type: 'junction-approach'; nodeId: string; segmentId: string; side?: 'left' | 'right' } + | { type: 'junction-corner'; nodeId: string; incomingRoadId: string; outgoingRoadId: string } +``` + +`station` 是相对道路中心线归一化弧长 `0..1`,而不是绝对经纬度或数组下标。它对顶点加减和轻微 OSM 几何调整更稳定;保存时还应记录 `anchorSnapshot`(当时位置、切线、道路长度、相邻 OSM node ID)作为重定位/冲突检测证据。 + +道路横断面拖拽默认写入 `road-interval` 约束:拖拽位置为 interval 中心,系统根据道路长度与邻近路口预留距离推导初始 `startStation` / `endStation`,并在两端使用明确的 `transition: 'smoothstep'` 回归基线。用户调整范围手柄时只更新该 interval,不存储鼠标轨迹。 + +## 分层求解规则 + +1. **基线层**:OSM → 当前道路模型和默认横断面。 +2. **现有参数层**:v1 road/connection/style overrides;可由简单拖拽写入,继续兼容。 +3. **几何约束层**:按语义锚点计算横断面、边界和路口局部形状;冲突时按明确优先级或提示用户处理。 +4. **派生层**:道路面、步行带、车道中心线、标线、停止线和 connector 必须一起重新计算,不能只移动视觉面。 + +## 操作、撤销和重导入 + +- UI 维护本地命令栈:pointer down 生成草稿,pointer move 更新临时 constraint,pointer up 合并为一个 `operation`;undo/redo 仅移动指针,不写文件。 +- 保存后写入完整约束文档和不可变 `operations` 记录;已保存编辑的撤销创建反向操作或禁用约束,不修改历史。 +- 重导入时先按 `roadId`/`nodeId` 精确匹配;失败时使用 `anchorSnapshot` 的 OSM node、距离和切线作候选匹配。匹配不唯一或偏差超阈值即标记 `conflicted`,不自动应用。 +- v1 覆盖项保留并逐步迁移:全路宽度仍是 road override;只有无法表达的局部调整进入 v2 constraints。 + +## 需要在产品边界确认后细化 + +- 路口角部是否由独立约束处理,还是将路口升级为可编辑的模板/参数对象。 +- 是否需要多人协作;若需要,operation log 必须具备 actor、revision 和并发合并策略。 + +## 已确认边界 + +OSM 中心线与拓扑保持只读。直接操纵不会新增 `centerline-control-point`、分段、合并或连接/断开拓扑操作;这类修改通过修正 OSM 后重新导入完成。v2 约束因此只作用在由中心线派生的横断面、边缘、步行带与路口细节。 + +首期范围包含道路外缘、步行带、车道分隔,以及路口进口、cutback 与转弯角部。路口必须是独立的 `junction-*` anchor,不应伪装成某一条道路末端的 edge offset;它会同时影响道路截断、路口面、connector、停止线、斑马线和标线。 diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/research/drawtonomy.md b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/drawtonomy.md new file mode 100644 index 0000000..d94ea54 --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/drawtonomy.md @@ -0,0 +1,26 @@ +# Drawtonomy:可验证的设计证据 + +本笔记仅基于本机 `/Users/que01/Project/drawtonomy` 的公开源码与文档。该 checkout 不包含编辑器 Canvas、指针事件、选择或历史实现;不能据此断言它具体如何处理拖拽状态机。 + +## 公开事实 + +- `README.md` 将其描述为 topology-aware lane、snap 与 point sharing;连接会随编辑保持。 +- `packages/drawtonomy-sdk/src/types.ts` 定义 `point`、`linestring`、`lane`:lane 用 `leftBoundaryId` / `rightBoundaryId` 引用边界,边界以 `pointIds` 引用点,`next`/`prev` 表示车道关系。 +- `packages/drawtonomy-sdk/src/exporter/osmToShapes.ts` 从 Lanelet2 导入时复用相同的点和 linestring,对相邻道路保留共享边界与方向反转信息。 +- `packages/drawtonomy-sdk/src/exporter/laneCenterline.ts` 从左右边界按归一化弧长采样,导出中心线和宽度;中心线是派生数据。 +- `docs/exporter.md` 指出可重新编辑的 SVG 内嵌完整 snapshot,导出器只从对象图读取数据。 + +## 可迁移原则 + +1. **拥有关系而非复制关系**:共享点/边界只保存一次,依赖对象通过 ID 引用。因此一个动作自然波及所有关联几何。 +2. **编辑源与展示/导出分离**:编辑对象图是源,中心线、边界渲染、导出格式是派生物。 +3. **显式拓扑**:车道连接不是靠距离猜测,而是用 `next` / `prev` 表示。 +4. **可重新打开的完整状态**:持久化内容足以重新构建编辑场景,而不是一张结果图。 + +## 不应直接照搬 + +Drawtonomy 是从空白画布或 Lanelet2 形状开始编辑的创作工具;本项目以 OSM 为权威输入、编译器推导道路横断面和路口。若完全改为点/边界对象图,会失去现有 OSM 重导入、规则推导和可追溯性,且制造第二个不一致的地图源。 + +## 对本项目的结论 + +借鉴其“语义对象图 + 依赖重算 + 共享锚点”的原则,但将持久化内容定义为附着在 OSM 派生语义 ID 上的约束,而不是迁移到 Drawtonomy 的自由形状 snapshot。 diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md new file mode 100644 index 0000000..87c5c10 --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/joint-solver.md @@ -0,0 +1,56 @@ +# 道路与路口的联合求解边界 + +## 当前依赖已经存在 + +`compileGeometry()` 目前以同一 `model` 和 `junctionPlans` 依次生成道路面、车道中心线、边缘线、控制设施、中心线/车道标线、步行带、connector 与路口面。它们不是孤立图层: + +```text +道路横断面 / 进口尺寸 + -> 道路面、步行带、车道偏移 + -> 车道线与 connector + -> 路口边界、cutback、转弯圆角 + -> 停止线、斑马线、箭头与诊断 +``` + +普通路口目前由 `compileJunctionPlans()` 的 approach、`cutbackMeters` 和 boundary 驱动;复合路口则由 `junctionTemplates.clusters` 和 `complex-junction.js` 的 core radius、进口包络和角部岛生成。直接操纵不能在最终 `roadSurface` / `intersectionSurface` 上独立移动顶点,否则会破坏这条依赖链。 + +## 提议的新增阶段 + +```text +OSM -> compileRoadModel -> 现有 v1 参数覆盖 + -> resolveDirectEditConstraints + -> editableRoadModel + editableJunctionPlans + -> 既有图层编译器(逐步接收扩展参数) -> GeoJSON +``` + +`resolveDirectEditConstraints` 的职责是: + +1. 将 `road-station` / `road-interval` 约束整理为道路横断面 profile; +2. 将道路末端 profile 与 `junction-approach` 约束合并,构建一致的进口截面; +3. 以 `junction-corner` / `junction-cutback` 约束生成可验证的 junction plan; +4. 在违反最小车道宽、相邻道路相交、交叉口连接线包络等不变量时,返回明确冲突而非偷偷修复; +5. 输出一个 handle manifest,使地图手柄能从同一语义模型读取位置、可拖动方向、受影响对象和可见的值。 + +道路 interval 的默认范围由求解器决定,而不是 UI 预设像素:避开两端的 junction cutback,优先取拖拽站点两侧可用长度的有限比例;若道路过短或与另一个约束重叠,预览返回可调整范围或冲突信息。 + +## 交互预览 + +拖动不触发文件写入或完整工作台重建。地图维护 `EditSession`: + +```text +pointer down: 读取 handle 的 semantic anchor,创建 draft command +pointer move: 投影鼠标位置 -> 立即更新 ghost 与 draft constraint -> 防抖服务端预览求解 +pointer up: 验证成功则压入本地命令栈;失败保留提示并回退到上一个有效预览 +save: 批量持久化 constraints + operations +compile: 用已保存文档运行权威全量编译 +``` + +浏览器 ghost 仅包含控制柄、辅助线与半透明预估轮廓,不承担权威道路几何。服务端复用正式编译的约束求解逻辑,返回受影响的少数 OpenLayers source(道路面、步行带、路口、标线、connector 和 handles);客户端替换它们,而非卸载 `MapCanvas` 或重新创建 `Map`。这与现有图层可见性和选择修复一致。 + +## 约束不变量 + +- 单车道最小宽度,例如 2.4m;道路横断面总宽度等于车道、边缘和步行带之和。 +- 一个站点的左右外缘不得交叉;相邻 profile 之间必须有可计算的过渡。 +- 进口截面必须在路口 cutback 处与路口边界连续。 +- connector、停止线和人行横道必须保持在所属道路/路口可用面内,否则产生阻塞性诊断。 +- 普通路口与复杂 cluster 不共用相同低层约束:前者锚定 node,后者锚定 cluster 和 arm,避免将 cluster 缩减为多个互相冲突的普通路口编辑。 diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md new file mode 100644 index 0000000..5bba9d5 --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/junction-tools.md @@ -0,0 +1,71 @@ +# JunctionTools 专用路口编辑工作区 + +## 结论 + +路口应从主地图的上下文控制柄升级为专用编辑工作区。主地图负责发现、选择和进入;`JunctionTools` 负责高密度的局部几何、拓扑和诊断操作。这不改变“OSM 中心线/拓扑只读”和“约束驱动派生输出”的原则。 + +## 入口与范围 + +从路口面或诊断进入,路由参数使用语义引用: + +```ts +type JunctionRef = + | { type: 'node'; id: string } + | { type: 'cluster'; id: string } +``` + +工作区加载活动 revision 中该 junction 的 context:路口面、外部进口、相邻道路的短上下文、车道/connector、步行带、停止线、斑马线、诊断和当前 constraints。保留返回主地图的入口与位置,避免用户失去全局方位。 + +## 统一 UI、不同求解器 + +普通 node 和复合 cluster 仍共享“进口、角部、控制设施、诊断”的用户心智模型;但 `JunctionTools` 根据 `JunctionRef` 提供不同内部投影: + +- node:按 OSM node 的 incoming/outgoing approaches 和 corners 编辑。 +- cluster:按对外 arms 编辑;内部短连接不作为用户可拖的普通路口边缘,避免约束彼此冲突。 + +这比强迫主地图显示一套通用控制柄更可靠,也比暴露多套产品工具更易理解。 + +## 快速拓扑拟合与更新 + +此处“拓扑更新”指重新求解**生成道路的可行拓扑**,不修改 OSM 图拓扑:进口横断面、车道分配、connector/movement、路口边界、步行带、停止线、斑马线和标线必须一起重算。 + +```text +JunctionTools 草稿操作 + -> 客户端即时 ghost + -> 防抖服务端局部 junction solve + -> 返回 fit 后的 junction plan、派生 GeoJSON、拓扑诊断 + -> 当前编辑器局部刷新;返回主地图后复用同一 preview +``` + +局部解必须校验最小车道宽、连接线包含性、surface 自交、控制设施可放置性与 cluster arm 连续性。无法拟合时返回最后有效几何与结构化诊断;不能把不合法的图形显示成已应用。 + +## 与 revision 的关系 + +`JunctionTools` 的草稿属于活动工作副本的 `EditSession`,不是独立导出文件。用户可以取消并丢弃草稿,或将有效操作合并到主工作区;之后仍通过普通保存和命名检查点进入 revision 历史。 + +## 预览与提交状态 + +“预览”“应用”“保存”是三个不同状态,不能把保存当作看见效果的前提: + +```text +首次拖拽 -> session draft + 即时 ghost +防抖服务端返回 -> JunctionTools 显示权威局部几何/诊断预览 +应用到工作区 -> 将有效 draft 合并为主工作区的未保存约束;主地图继续显示预览 +保存 -> 将活动副本写入持久化文档 +保存检查点 -> 冻结为不可变 revision +取消 -> 丢弃未应用的 JunctionTools draft,恢复进入前的工作区状态 +``` + +预览必须包含所有受影响的派生对象:道路面、路口面、步行带、车道中心线、connector/movement、停止线、斑马线与标线,以及拟合失败时的诊断和最后一个有效结果。 + +## 会话边界 + +一次 `JunctionTools` 会话严格聚焦一个 `JunctionRef`。进入相邻路口前,用户必须应用或取消当前草稿;系统不允许两个路口草稿在同一个局部 solver session 内并存。已应用但未保存的编辑属于主工作区,可在主地图的普通 undo/redo 中管理。 + +## 与道路编辑的所有权 + +主地图 road editor 只拥有两个 junction reserve 之间的内部 road interval。路口两端的 reserve 在地图上可见但不可由道路区间 handle 覆盖;`JunctionTools` 独占 reserve 内的 approach width、transition、cutback 和 corner 约束。对同一进口,显式 `junction-approach` constraint 优先于道路 profile,solver 在边界自动求连续过渡。 + +## 当前阶段的延后边界 + +`JunctionTools` 会成为核心竞争力,但当前任务只设计其对象边界、会话、预览和约束合同。高级复合路口模板、多个路口联动编辑、控制设施的单对象创作、协作与全面拓扑创作均延后,避免这些问题遮蔽直接编辑基础链路的验证。 diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/research/revisions.md b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/revisions.md new file mode 100644 index 0000000..39413bf --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/research/revisions.md @@ -0,0 +1,64 @@ +# 可复现存盘与 OSM 重导入 + +## 问题 + +仅让最新的 `native-road-overrides.json` 跟随当前导入 OSM,会使用户无法安全比较“原始 OSM + 精修”和“新的 OSM + 重放精修”。输出 GeoJSON 又不足以解释或再次编辑场景。 + +## 建议:不可变 revision,活动工作副本 + +工作区有一个可编辑的活动副本;每个 revision 都是可重新编译、可审计的冻结快照。OSM 重导入创建新的候选 revision 或工作分支,永不原地覆盖已保存 revision。 + +```text +工作区 + active/ 当前暂存编辑 + revisions/ + rev-0001/ 不可变的“导入基线” + rev-0002/ 命名检查点 + rev-0003/ 基于新 OSM 的候选版本 +``` + +每个 revision 至少保存: + +```ts +interface RoadRevisionManifest { + schema: 'road-workbench-revision/v1' + id: string + createdAt: string + label?: string + parentRevisionId?: string + source: { + osmFile: string + osmSha256: string + areaConfigFile: string + areaConfigSha256: string + } + documents: { + nativeRoadOverrides: string + directEdits: string + trafficSignals: string + } + compiler: { + packageVersion: string + gitCommit?: string + geometrySchema: string + } + outputs?: { manifest: string; compiledSha256: string } +} +``` + +OSM、area config 和各 JSON 文档均在 revision 目录中复制,manifest 保存 digest。派生输出可作为缓存/审阅证据保存,但“复现来源”始终是输入文档和 compiler identity;恢复时应可重新编译验证 digest,并提示编译器版本差异。 + +## 对冲突体验的作用 + +- 在当前 revision 内修改 OSM 并重导入,不会破坏旧 revision 的可用性。 +- 用户可以在新 revision 上尝试约束重放并获得精确/可疑/失效状态,同时随时返回旧版本查看原效果。 +- 确认 rebase 后才将新 revision 设为活动版本;失败也不会丢失已精修场景。 +- 保存操作记录同样随 revision 冻结,便于审计;活动副本的未保存 undo/redo 不进入不可变 revision。 + +## 已确定的创建策略 + +- 导入 OSM 自动创建不可变基线 revision。 +- 用户明确执行“保存检查点”时创建带名称的不可变 revision。 +- 普通保存、草稿预览和重新编译只更新活动副本,不自动制造 revision。 + +这一策略以可审阅的检查点承载长期历史,避免把高频拖拽/保存变成难以浏览的版本噪声。 diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/task.json b/.trellis/tasks/08-26-direct-manipulation-road-editor/task.json new file mode 100644 index 0000000..cddee84 --- /dev/null +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/task.json @@ -0,0 +1,45 @@ +{ + "id": "direct-manipulation-road-editor", + "name": "direct-manipulation-road-editor", + "title": "直接操纵道路编辑工作台", + "description": "探索类似 Drawtonomy 的直接操纵道路编辑,保持结构化 overrides、可审计性和可重编译性", + "status": "planning", + "dev_type": "feature", + "scope": "road-editor", + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-26", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [ + "08-26-direct-edit-documents", + "08-26-direct-edit-solver-api", + "08-26-direct-edit-map-editor", + "08-26-direct-edit-junction-tools" + ], + "parent": null, + "relatedFiles": [ + "src/compile/compiler.js", + "src/compile/native-road.js", + "src/compile/complex-junction.js", + "src/compile/layer-manifest.js", + "src/geometry/lane-geometry.js", + "workbench/server.js", + "workbench/client/src/App.tsx", + "workbench/client/src/components/MapCanvas.tsx", + "workbench/client/src/map/layers.ts", + "workbench/client/src/lib/api.ts", + "workbench/client/src/types/state.ts", + "test/index.js", + "test/fixtures.js" + ], + "notes": "父任务:持有需求集、design.md 权威合约、跨子任务验收与第 8 步集成验证。实现落在三个子任务。", + "meta": {} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0dc6b3a..1ff61fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "@types/react-dom": "^19.0.3", "prettier": "^3.9.6", "typescript": "^5.7.3", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^4.1.11" } }, "node_modules/@esbuild/aix-ppc64": { @@ -450,6 +451,13 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "http://172.16.1.86:4873/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "http://172.16.1.86:4873/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -823,6 +831,31 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "http://172.16.1.86:4873/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "http://172.16.1.86:4873/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "http://172.16.1.86:4873/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "http://172.16.1.86:4873/@types/estree/-/estree-1.0.9.tgz", @@ -856,6 +889,119 @@ "@types/react": "^19.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "http://172.16.1.86:4873/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "http://172.16.1.86:4873/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "http://172.16.1.86:4873/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "http://172.16.1.86:4873/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "http://172.16.1.86:4873/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "http://172.16.1.86:4873/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "http://172.16.1.86:4873/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@zarrita/storage": { "version": "0.2.0", "resolved": "http://172.16.1.86:4873/@zarrita/storage/-/storage-0.2.0.tgz", @@ -866,6 +1012,33 @@ "unzipit": "2.0.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "http://172.16.1.86:4873/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "http://172.16.1.86:4873/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "http://172.16.1.86:4873/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "http://172.16.1.86:4873/csstype/-/csstype-3.2.3.tgz", @@ -879,6 +1052,13 @@ "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "http://172.16.1.86:4873/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.24.2", "resolved": "http://172.16.1.86:4873/esbuild/-/esbuild-0.24.2.tgz", @@ -920,6 +1100,44 @@ "@esbuild/win32-x64": "0.24.2" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "http://172.16.1.86:4873/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "http://172.16.1.86:4873/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "http://172.16.1.86:4873/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fflate": { "version": "0.8.3", "resolved": "http://172.16.1.86:4873/fflate/-/fflate-0.8.3.tgz", @@ -975,6 +1193,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "http://172.16.1.86:4873/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "http://172.16.1.86:4873/nanoid/-/nanoid-3.3.18.tgz", @@ -1003,6 +1231,20 @@ "fflate": "^0.8.0" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "http://172.16.1.86:4873/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ol": { "version": "10.10.0", "resolved": "http://172.16.1.86:4873/ol/-/ol-10.10.0.tgz", @@ -1043,6 +1285,13 @@ "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "http://172.16.1.86:4873/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbf": { "version": "5.1.2", "resolved": "http://172.16.1.86:4873/pbf/-/pbf-5.1.2.tgz", @@ -1062,6 +1311,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "http://172.16.1.86:4873/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "http://172.16.1.86:4873/postcss/-/postcss-8.5.26.tgz", @@ -1228,6 +1490,13 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "http://172.16.1.86:4873/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "http://172.16.1.86:4873/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1238,6 +1507,64 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "http://172.16.1.86:4873/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "http://172.16.1.86:4873/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "http://172.16.1.86:4873/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "http://172.16.1.86:4873/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "http://172.16.1.86:4873/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "http://172.16.1.86:4873/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "http://172.16.1.86:4873/typescript/-/typescript-5.7.3.tgz", @@ -1333,12 +1660,119 @@ } } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "http://172.16.1.86:4873/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "http://172.16.1.86:4873/web-worker/-/web-worker-1.5.0.tgz", "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "license": "Apache-2.0" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "http://172.16.1.86:4873/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/xml-utils": { "version": "1.10.2", "resolved": "http://172.16.1.86:4873/xml-utils/-/xml-utils-1.10.2.tgz", diff --git a/package.json b/package.json index a15955f..c920cc6 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "build": "vite build --config workbench/client/vite.config.ts", "dev": "vite --config workbench/client/vite.config.ts", "test:client": "tsc --noEmit -p workbench/client/tsconfig.json", - "format": "prettier --write \"{src,bin,test,workbench}/**/*.{js,ts,tsx,css,html}\" \"*.{json,js}\"", - "format:check": "prettier --check \"{src,bin,test,workbench}/**/*.{js,ts,tsx,css,html}\" \"*.{json,js}\"" + "test:client:unit": "vitest run --passWithNoTests", + "format": "prettier --write \"{src,bin,test,workbench}/**/*.{js,ts,tsx,css,html}\" \"*.{json,js,ts}\"", + "format:check": "prettier --check \"{src,bin,test,workbench}/**/*.{js,ts,tsx,css,html}\" \"*.{json,js,ts}\"" }, "dependencies": { "fflate": "0.8.3", @@ -29,6 +30,7 @@ "@types/react-dom": "^19.0.3", "prettier": "^3.9.6", "typescript": "^5.7.3", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "4.1.11" } } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..33e23c4 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +// 只覆盖客户端纯逻辑(EditSession 命令栈、previewSeq 仲裁、handle 事件到约束值的投影、米制换算)。 +// OpenLayers 地图行为不在此处测试,见各子任务 implement.md 的手测清单。 +export default defineConfig({ + test: { + include: ['workbench/client/src/**/*.test.ts'], + environment: 'node', + }, +});