feat: drag road handles with ghost and server preview
Three things, kept in one commit because they touch overlapping hunks of the same two files and this environment has no interactive hunk staging. Splitting them by file would have drawn boundaries that misrepresent what changed. 1. Step 4 of the map editor. A native OpenLayers PointerInteraction turns a drag into a clamped constraint value, the ghost source shows it immediately, and the solver's answer replaces a parallel set of preview layers while the baseline layers are hidden rather than overwritten. Preview requests debounce at 80 ms, pointerup flushes without waiting, and a newer request aborts the one in flight; EditSession decides which answers count. Handle positions come from the clamped value, so a handle stops at its limit instead of following the cursor. Three of the four drag capabilities are live: edge offset, sidewalk width, lane divider. 2. Road edge handles were drawn on the wrong side. offsetLine() offsets counter-clockwise from the direction of travel and sidewalks use `heading + (side === 'left' ? -90 : 90)`, so left is `tangent - 90`; makeRoadHandles() placed the left handle at `tangent + 90`, over the right kerb. Dragging the visually-left handle moved the right edge. Fixed on both sides of the wire, with regression tests that name the sides geographically rather than by axis sign. 3. Roads the junctions geometrically fill are now read-only. The 0.45 cap per reserve made the existing `unavailable` branch unreachable, so a 14.5 m stub between two junctions was offered a 1.5 m editable band with no room for the transitions a road-interval constraint needs. Greying only affects the manifest: constraints already saved against such a road keep being solved, so the geometry output is unchanged and the fixture baselines do not move. Range handles are built and unit-tested but hidden behind `intervalEditingSupported`: compileGeometry() reads neither profile.interval nor profile.transitions, so every edit applies to the whole road and the control would have had no effect. Recorded in research/interval-not-applied.md, which also blocks one PRD acceptance criterion. The ol-ext probe stays in the tree as a manual harness; ol-ext is still not a dependency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# 区间范围手柄由客户端合成
|
||||
|
||||
第 4 步的实现决定,与 `design.md`「Handle manifest」小节的字面规则有偏离,故单独记录。
|
||||
决定人:dingkang,2026-08-27。
|
||||
|
||||
## 偏离了什么
|
||||
|
||||
`design.md` 写明:
|
||||
|
||||
> 服务端从同一语义模型生成手柄清单,**客户端不自行推导手柄位置或可拖方向**。
|
||||
|
||||
而区间范围手柄(第 4 类可拖能力)由客户端 `selection.ts` 的 `intervalRangeHandles()` 合成,
|
||||
位置由 `meters.ts` 的 `coordinateAtStation()` 从中心线插值得到。
|
||||
|
||||
## 为什么这样定
|
||||
|
||||
**1. manifest schema 装不下它。**
|
||||
`EditHandle.kind` 的类型是 `RoadConstraintKind`,而 `design.md` 的 kind 表明确声明
|
||||
「这 6 个 kind 与 PRD 首期范围一一对应,没有多余项也没有缺口」。
|
||||
范围手柄不是一种约束——它改的是既有约束 `anchor` 的 `startStation` / `endStation`。
|
||||
要下发就得加第 7 个求解器根本不认的假 kind,或加一个平行数组。为派生数据改合约不划算。
|
||||
|
||||
**2. 它的位置是服务端已发数据的纯函数。**
|
||||
区间来自服务端算好并下发的 `anchor.startStation` / `endStation`,
|
||||
合法窗口来自 `manifest.reserves`,拖拽轴是道路切线。
|
||||
服务端下发等于把自己刚发的东西再算一遍回显,正是 `cross-layer-thinking-guide.md`
|
||||
警告的「derived state 另立第二个游标」。
|
||||
|
||||
**3. 决定性的一条:客户端本来就需要 station → 坐标的插值。**
|
||||
`design.md` 要求 ghost 画「半透明预估轮廓」,即在道路上标出受影响的区间带。
|
||||
画这条带子必须把 station 插值成坐标,**与范围手柄由谁产出无关**。
|
||||
`coordinateAtStation()` 因此是客户端的既有需求;有了它,合成范围手柄几乎免费,
|
||||
服务端改动买不到任何东西。
|
||||
|
||||
**4. `projectIntervalEnd()` 已交付并测试**,签名恰好就是这些输入。
|
||||
|
||||
## 为什么认为符合规则的意图
|
||||
|
||||
那条规则防的是客户端发明**语义**——哪个约束、哪个方向、什么范围合法。
|
||||
范围手柄一样都没发明:
|
||||
|
||||
| 语义 | 来源 |
|
||||
| --- | --- |
|
||||
| 合法窗口 | `manifest.reserves`(服务端) |
|
||||
| 区间当前值 | 约束 `anchor` 的 station(服务端) |
|
||||
| 拖拽轴 | 道路中心线切线(编译模型,服务端) |
|
||||
| 屏幕位置 | 客户端插值 ← **仅此一项是派生的** |
|
||||
|
||||
客户端只做「把服务端已选定的 station 插值成屏幕位置」这一件事。
|
||||
|
||||
## 已接受的代价
|
||||
|
||||
`coordinateAtStation()` 是 `src/compile/direct-edit-solver.js` 里 `coordinateAt()` 的
|
||||
第二份实现。二者都用弧长插值,但分属不同 runtime(CommonJS 服务端 / ESM 浏览器),
|
||||
且客户端那份只服务于 ghost 渲染,不参与任何持久化或求解。
|
||||
`meters.ts` 的注释已标明这层关系,防止后来者误以为可以随意改动其中一份。
|
||||
|
||||
## 若要改回服务端下发
|
||||
|
||||
新增 manifest 字段 `rangeHandles: IntervalRangeHandle[]`(不要塞进 `handles`),
|
||||
由服务端用既有 `coordinateAt()` 算位置。客户端删掉 `intervalRangeHandles()` 即可,
|
||||
`projectIntervalEnd()` 与拖拽链路不受影响。
|
||||
`coordinateAtStation()` 仍需保留,因为 ghost 的区间带还要用。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 几何编译器不施加 `road-interval` 区间
|
||||
|
||||
第 4 步手测时发现:区间范围手柄拖动有 ghost 反馈,但松手后几何毫无变化。
|
||||
经排查这是服务端的能力缺口,不是客户端拖拽逻辑的问题。
|
||||
|
||||
## 事实
|
||||
|
||||
`grep "\.interval\b" src/compile/native-road.js` 返回空。
|
||||
|
||||
求解器写入了区间与过渡,几何阶段一个都不读:
|
||||
|
||||
| profile 字段 | solver 写入 | `compileGeometry()` 读取 |
|
||||
| --- | --- | --- |
|
||||
| `edgeOffsets.left/right` | ✅ | ✅ `centerlineShift`(native-road.js:825) |
|
||||
| `widthMeters` | ✅ | ✅ native-road.js:832 |
|
||||
| `sidewalkWidths.left/right` | ✅ | ✅ 真实宽度,`sidewalkRing`(native-road.js:1666-1671) |
|
||||
| `laneDividerOffsets` | ✅ | ✅ native-road.js:1946 |
|
||||
| **`interval`** | ✅ direct-edit-solver.js:274-277、418 | ❌ **零消费者** |
|
||||
| **`transitions`** | ✅ direct-edit-solver.js:293、303、322 | ❌ **零消费者** |
|
||||
|
||||
## 后果
|
||||
|
||||
1. **每一次直接编辑都作用于整条路。** `anchor.startStation` / `endStation` 被存储、被
|
||||
`validateEditDocument()` 校验、被带进 `resolveDirectEditConstraints()` 的结果,然后被忽略。
|
||||
2. **区间范围手柄在几何上不可能有效果。** 客户端的 `intervalRangeHandles()`、
|
||||
`projectIntervalEnd()`、`coordinateAtStation()` 都正确且有单测,但下游无人接收。
|
||||
3. **`transition: 'smoothstep' | 'linear'` 是死字段。** 父任务 design.md 承诺
|
||||
「作用于 interval 两端回归基线的过渡段」,实际没有任何过渡。
|
||||
|
||||
## 已采取的处置
|
||||
|
||||
`workbench/client/src/edit/flag.ts` 增加 `intervalEditingSupported = false`,
|
||||
范围手柄的 UI 据此隐藏。纯逻辑与测试全部保留。
|
||||
|
||||
理由:一个拖起来有反馈、松手却没效果的控件比没有这个控件更糟——它会持续产生
|
||||
bug 报告,并让人怀疑整个编辑器的其余部分。区间生效的那个提交把这个常量翻成 `true` 即可,
|
||||
客户端不需要其他改动。
|
||||
|
||||
## 对验收标准的影响
|
||||
|
||||
父任务 prd.md 的这条**当前无法通过**:
|
||||
|
||||
> 区间范围手柄可修改影响区间,两端平滑过渡回基线。
|
||||
|
||||
`08-26-direct-edit-map-editor/prd.md` 的同名条目同理。这不是客户端欠工,
|
||||
而是需要 `compileGeometry()` 具备按 station 施加横断面 profile 的能力。
|
||||
|
||||
## 若要实现(未排期)
|
||||
|
||||
用户 2026-08-27 判断影响不大,故未开任务,仅记录。真要做时的形状:
|
||||
|
||||
1. `native-road.js` 的横断面生成需要接受「沿中心线变化的 profile」而非单一常量宽度。
|
||||
目前 `applyDirectEditProfiles()`(约 818-838 行)返回的是整条路一个 `widthMeters`
|
||||
和一次 `offsetLine()` 整体平移,没有沿 station 变化的余地。
|
||||
2. 区间两端按 `transitions[kind]` 做 smoothstep / linear 插值回基线值。
|
||||
3. 车道线、标线、connector、步行带都由横断面派生,必须一并跟随,否则会脱节
|
||||
(父任务 research/joint-solver.md 的依赖链)。
|
||||
4. `test/fixtures` 基线会变化,需要显式 update-baseline 并人工核对。
|
||||
|
||||
这是 `compileGeometry` 的实质改动,属已归档的 `direct-edit-solver-api` 任务范围。
|
||||
@@ -22,7 +22,8 @@
|
||||
"08-26-direct-edit-documents",
|
||||
"08-26-direct-edit-solver-api",
|
||||
"08-26-direct-edit-map-editor",
|
||||
"08-26-direct-edit-junction-tools"
|
||||
"08-26-direct-edit-junction-tools",
|
||||
"08-27-junction-dominated-roads"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
68
.trellis/tasks/08-27-junction-dominated-roads/implement.md
Normal file
68
.trellis/tasks/08-27-junction-dominated-roads/implement.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 实施计划
|
||||
|
||||
轻量任务,一个提交即可。门禁不过就停。
|
||||
|
||||
前置:无。客户端置灰渲染路径已由 `08-26-direct-edit-map-editor` 第 3 步交付并有单测覆盖,
|
||||
本任务只补服务端输入。
|
||||
|
||||
## 关键决定:置灰只影响手柄,不影响求解
|
||||
|
||||
`editable: false` 只改变 handle manifest,**不改变 `solveConstraints()` 的行为**。
|
||||
落在被置灰 segment 上的既有约束仍然照常应用、照常参与几何编译。
|
||||
|
||||
理由:否则本次改动会让升级后的编译静默丢掉用户已保存的编辑——一个 UI 可编辑性的判断
|
||||
不该回溯否决已持久化的意图。用户想撤销这类编辑,走 undo / 禁用约束的正常路径。
|
||||
|
||||
这也让「几何输出逐字节不变」这条验收标准成立,从而使本步骤可安全回滚。
|
||||
|
||||
## 1. 判据与置灰
|
||||
|
||||
- 目标:可编辑带短于道路宽度的 segment,其全部 `road-*` 手柄 `editable: false`。
|
||||
- 位置:`src/compile/direct-edit-solver.js` 的 `makeRoadHandles()`。
|
||||
该函数已算出 `start` / `end` / `length` / `width`,判据是纯本地计算,不需要新数据。
|
||||
- 实现要点:
|
||||
- 带长米数 `(end - start) * length`,与 `width`(该 segment 双向宽度之和)比较。
|
||||
- 复用既有 `baseDisabled` 机制与既有文案「该道路全部位于路口保留区,请进入 JunctionTools 编辑。」,
|
||||
不新增第二套提示语。
|
||||
- 保留原 `unavailable` 判断作为兜底,两者取或。
|
||||
- 被置灰的 segment 仍然产出手柄(位置照旧),只是 `editable: false` 且带 `disabledReason`。
|
||||
- 不改:`junctionReserves()` 的 0.45 上限、cutback 推导、`solveConstraints()`、junction 手柄。
|
||||
|
||||
## 2. 测试
|
||||
|
||||
- 位置:`test/direct-edit-solver.js`。
|
||||
- 覆盖:
|
||||
- 短路段(带长 < 宽度)的全部 `road-*` 手柄 `editable: false` 且 `disabledReason` 非空。
|
||||
- 正常路段 `editable: true` 且无 `disabledReason`。
|
||||
- 判据边界:带长略小于 / 略大于宽度两侧各一例。
|
||||
- junction 手柄仍 `editable: true`。
|
||||
- 置灰 segment 上的既有约束仍被求解(`constraintStates` 里 `applied: true`),
|
||||
证明置灰没有回溯否决已保存编辑。
|
||||
|
||||
## 3. 验证
|
||||
|
||||
```bash
|
||||
npm run test # 含 test/direct-edit-solver.js
|
||||
npm run test:client # 客户端类型
|
||||
npm run test:client:unit # 客户端纯逻辑
|
||||
npm run format:check
|
||||
npm run build
|
||||
```
|
||||
|
||||
几何不变的实测(本任务的核心安全性证明):对 `test/fixtures/fengshu-er-road.osm`
|
||||
在改动前后各跑一次 `compileGeometry`,比较全部输出图层的 JSON,必须完全一致。
|
||||
`npm run test` 里的 fixture 基线测试已覆盖这条,若基线有 diff 即为回归。
|
||||
|
||||
手测(可选,`directEdit` 开关打开):选中 `test/fixtures` 里那 6 段短路之一,
|
||||
应看到灰色手柄且 header 显示原因。这是父任务 prd 那条验收标准第一次真正可演示。
|
||||
|
||||
## 4. 门禁
|
||||
|
||||
- 几何输出零变化(fixture 基线无 diff)。
|
||||
- 正常路段行为与当前 main 完全一致。
|
||||
- 既有约束不因置灰而失效。
|
||||
|
||||
## 回滚点
|
||||
|
||||
单文件单函数改动,`git revert` 即可回到当前行为。
|
||||
客户端不需要任何配合改动,回滚后灰色手柄自然消失,回到「空真」状态。
|
||||
87
.trellis/tasks/08-27-junction-dominated-roads/prd.md
Normal file
87
.trellis/tasks/08-27-junction-dominated-roads/prd.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 路口主导的短路段整条置灰
|
||||
|
||||
父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。约束模型与编辑所有权的权威定义在父任务 `design.md`。
|
||||
|
||||
## 问题
|
||||
|
||||
`src/compile/direct-edit-solver.js` 里 `editable: false` 的唯一来源是 `unavailable`:
|
||||
|
||||
```js
|
||||
const start = Math.min(1, startReserve);
|
||||
const end = Math.max(0, 1 - endReserve);
|
||||
const unavailable = start >= end;
|
||||
```
|
||||
|
||||
但 `junctionReserves()` 给每端保留区的比例有硬上限 `Math.min(0.45, cutback / length)`,
|
||||
于是 `start ≤ 0.45`、`end ≥ 0.55`,`start >= end` 结构上永远为假。
|
||||
**该分支是死代码,连带那句提示语「该道路全部位于路口保留区,请进入 JunctionTools 编辑。」永远不可能显示。**
|
||||
|
||||
实测 `test/fixtures/fengshu-er-road.osm`:126 个道路手柄,0 个 `editable: false`。
|
||||
|
||||
后果是短路段拿到了一条无意义的编辑带(`test/fixtures/fengshu-er-road.osm`,20 个有保留区的段):
|
||||
|
||||
| 路长(米) | 总宽(米) | 窗口 | 可编辑带(米) |
|
||||
| --- | --- | --- | --- |
|
||||
| 14.5 | 9.8 | [0.45,0.55] | 1.5 |
|
||||
| 15.4 | 9.8 | [0.45,0.55] | 1.5 |
|
||||
| 18.6 | 9.8 | [0.45,0.55] | 1.9 |
|
||||
| 19.1 | 9.8 | [0.45,0.55] | 1.9 |
|
||||
| 19.2 | 9.8 | [0.45,0.55] | 1.9 |
|
||||
| 19.6 | 6.5 | [0.45,0.55] | 2.0 |
|
||||
| 82.2 | 9.8 | [0.17,0.83] | 54.9 |
|
||||
| 199.2 | 9.8 | [0.00,0.93] | 185.6 |
|
||||
|
||||
第一行那条路只有 14.5 米长、9.8 米宽,cutback 是 `max(宽度) × 1.4 ≈ 13.7` 米——
|
||||
**每一端**的路口都要这条路的 94%,两端合计 188%,远超全长。上限把它压成各 45%,
|
||||
凭空造出中间 10%(1.5 米)的「可编辑带」。
|
||||
|
||||
这违反两条已写明的约定:
|
||||
|
||||
- 父任务不变量「相邻 profile 之间必须有可计算的过渡」。约束默认 `transition: 'smoothstep'`
|
||||
作用于区间两端回归基线,1.5 米的区间两侧紧贴保留区,没有任何余量做过渡。
|
||||
- 父任务「编辑所有权」:主地图只编辑两个 junction reserve **之间**的道路内部 interval。
|
||||
这种路的真实路口几何占满全长,根本不存在「之间」。
|
||||
|
||||
## 目标
|
||||
|
||||
让路口几何占满的短路段整条归 JunctionTools,恢复那个分支的可达性,
|
||||
并使父任务 prd 的验收标准「落在 junction reserve 内的手柄不可拖动,并提示进入 JunctionTools」
|
||||
从空真变成可演示。
|
||||
|
||||
## 判据
|
||||
|
||||
**可编辑带长度(米)< 道路总宽度(米)→ 该 segment 的所有道路手柄 `editable: false`。**
|
||||
|
||||
用物理长度而非归一化 station,理由是过渡段需要的是实际距离,与道路长短无关。
|
||||
一条比自身宽度还短的编辑带装不下横断面的平滑变化。
|
||||
|
||||
在上表数据上分界干净,无边界模糊样本:1.5 < 9.8 置灰;54.9 > 9.8 保留。
|
||||
恰好切出可编辑带 < 10 米的那 6 段。
|
||||
|
||||
判断必须在上限生效**之后**用实际窗口算,而不是拿未截断的 `cutback / length` 之和——
|
||||
后者与「过渡段放不下」这件事没有直接关系。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 可编辑带短于道路宽度的 segment,其全部 `road-*` 手柄 `editable: false` 且带 `disabledReason`。
|
||||
- [ ] `disabledReason` 复用既有文案,引导进入 JunctionTools。
|
||||
- [ ] 可编辑带不短于道路宽度的 segment,行为与当前 main 完全一致(手柄数量、位置、`editable: true`)。
|
||||
- [ ] 置灰不改变 `reserves` 本身,也不改变任何几何输出:`compileGeometry` 结果与置灰前逐字节一致。
|
||||
- [ ] 置灰的 segment 仍然出现在 manifest 中(不是被删除),否则客户端无法解释为什么不能编辑。
|
||||
- [ ] junction 手柄不受影响,仍然 `editable: true`。
|
||||
- [ ] `test/direct-edit-solver.js` 覆盖:短路段全部置灰、正常路段不受影响、判据边界(带长 ≈ 宽度)。
|
||||
- [ ] `npm run test`、`npm run test:client`、`npm run test:client:unit`、`npm run format:check`、`npm run build` 全绿。
|
||||
- [ ] 客户端无需改动即可显示灰色手柄与原因(`08-26-direct-edit-map-editor` 已实现该路径)。
|
||||
|
||||
## 不做
|
||||
|
||||
- 不改 `junctionReserves()` 的 0.45 上限本身。上限保护的是 reserve 语义,改它会牵动 junction 侧几何。
|
||||
- 不改 cutback 的推导公式。
|
||||
- 不实现 JunctionTools 侧的编辑能力(属 `08-26-direct-edit-junction-tools`)。
|
||||
- 不做客户端改动。
|
||||
- 不把置灰的 segment 从 manifest 里移除。
|
||||
|
||||
## 顺序依赖
|
||||
|
||||
与 `08-26-direct-edit-map-editor` 的第 4、5 步无依赖,可并行或后置。
|
||||
客户端置灰渲染路径已在该任务第 3 步交付并有单测覆盖,本任务只补上服务端的输入。
|
||||
26
.trellis/tasks/08-27-junction-dominated-roads/task.json
Normal file
26
.trellis/tasks/08-27-junction-dominated-roads/task.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "junction-dominated-roads",
|
||||
"name": "junction-dominated-roads",
|
||||
"title": "路口主导的短路段整条置灰",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-27",
|
||||
"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": {}
|
||||
}
|
||||
Reference in New Issue
Block a user