# 区间范围手柄由客户端合成 第 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 的区间带还要用。