37-entry-lock-and-area-limits.md 11 KB

37: 进区锁定与相机区域限位

What to build: 点「进入系统」或「归位」后进入主场景区并锁定:中心点被约束在区内(拖到边界软回弹)、滚轮最远只到归位默认视角(本部署 12,000 m)、相机不钻地形;「返回全球」解除限位并飞回自由全景;全球态闲置 10 秒(2026-09-30 由 5 秒改定)回到程序初始态。这是需求①「限高限低,不许超过限制范围」的落地。

Blocked by: 36

Status: resolved Labels: ready-for-agent

契约来源

  • 规格:cockpit-primary-area-guardrails/spec.md。
  • 决策:cockpit-primary-area-guardrails/issues/03-camera-contract-composition-and-entry-state.md(已 resolved);决策记录 docs/adr/0009-primary-area-camera-limits-and-entry-state.md。
  • 既有契约:ADR 0008(三键分工与阻尼)不得改动。

验收

  • areaState: 'locked' | 'global' 成为与 ground/pipeline 同层的全局视图条件;切一级模式不重置。
  • 进区后 range 上限=map.home.range(本部署 12,000 m);与管线视图 [400, 6000]、RANGE_LIMITS.min 三者取交集,区内管线视图因此与现状一致、无需额外收紧。
  • 中心点约束在 primaryArea.bounds 内:拖拽时允许最多框短边 5% 的越界,松手按既有阻尼弹回;shown.pivot 最终回到框内,越界只出现在拖拽过程。
  • 防入地:相机离地高度 ≥ 正下方地形 + 10 m(可微调);不足时夹 range(不夹 pitch、不动 pivot);瓦片未加载时退回固定下限。
  • 「返回全球」:解除限位 + 阻尼飞到程序初始态全景(heading 0 / pitch −60 / range 20,000,000),之后完全自由。
  • 全球态闲置 10 秒(2026-09-30 维护者由 5 秒改为 10 秒,契约同步)未操作 → 回程序初始态(镜头回初始全景 + 星球自转恢复 + 屏幕中央闪烁「点击进入系统」),不重置一级模式与筛选;任何交互即重置计时。
  • map.intro.enabled=false(走查自隔离)时按 global 自由视角启动、不弹引导页。
  • 顶栏:区内显示「归位」+「返回全球」;区外只显示一个「归位/进入」。
  • 三键分工与阻尼手感不变;限位只在输入时生效,无逐帧主动回拉(沿用现状 clamp 行为)。
  • ADR 0009 与实现一致;偏差需回写票面并经维护者确认。
  • 证据:纯逻辑断言(限位解析 / 状态转移 / 软区夹取 / 防入地)+ 一条可复现真机走查(进入 → 锁定 → 拉远被夹 → 拖到边界回弹 → 返回 → 闲置回引导)。

进度:100%

已完成(2026-09-28)。 本票是限位与状态机的落地;雾气(票 38)仍由下游票负责, 它消费本票的 areaState 与 range_max。

需要维护者裁定的一处(已裁定)

验收第 3 条「中心点约束在 bounds 内」与「点选定位」在契约里没有交界: cockpit-primary-area-guardrails/spec.md 自己把它记为「联动风险,未裁决,留给平行图 03」, 而平行图 03 收口时只裁了 range/高度(range ∈ [120, range_max]、h ≥ 地形+10 m), 没有裁中心点。这个缺口是实质性的:监测点散布全县(影像 105×122 km), 主场景区只框了县城 13.4×11 km,硬夹会把绝大多数点位挡在区外、点选后镜头停在框边而不是点位上 (mock 数据 lon 110.141、本部署框西界 110.329,直接落到框外十几公里)。

维护者 2026-09-28 裁定:

  1. 中心点约束只管手动三键;点选/定位是例外,镜头照常飞到框外点位(A 选项);
  2. 拖拽手感=「左键按紧拖动时向外阻尼移动直到最大限度停止,松开左键自动弹回」。

两条都已落进 docs/adr/0009(ADR 与实现一致)与本票实现。附带约束: 拖拽从框外起手时不回弹(否则刚定位到的点位一按鼠标就被拽回框边)——这条是第 1 条的必然推论。

改动

  • 新增 domain/areaLimits.js:限位解析(区域 × 视图 × 全局三处取交集)、软区/硬框夹取、 防入地夹取、状态转移(只有「返回全球」能解除)、顶栏形态、闲置判定。纯函数、不 import cesium。
  • domain/camera.js:把程序初始态全景提成 INITIAL_PANORAMA 常量(返回全球与首帧占位共用一份)。
  • services/cameraControl.js:新增 setRangeLimits(与既有 setPitchLimits 对称)、 getRangeLimits、clampToLimits;滚轮/setHpr/applyViewPreset 全部改走生效区间(含区域上限与防入地); 拖拽经软区夹取、松手走 springBackIntoBounds();定位豁免靠「按下时是否已在框外」判定。
  • services/mapService.js:applyAreaLimits() 是唯一的合成点;setAreaState('enter'|'returnGlobal') 是唯一的转移入口(归位即进区);宿主读数 data-map-area-state / -area-locked / -range-max; getAreaLimits() / getAreaState() 只读出口;DEV 把手 enterArea / returnGlobal。
  • services/cesiumEngine.js:groundHeightBelowCamera() / cameraHeight() —— 防入地要的是 相机正下方的地形(相机与 pivot 可能隔着一个山脊,用 pivot 的地形会在斜坡上误判)。
  • stores/filterStore.js:areaState 进 store(全局视图条件,与 viewMode 同层,切一级模式不重置)。
  • CockpitShell.vue:顶栏两形态 chip、区内状态读数、闲置计时(只在 global 态、任何交互重置、 监听可拆)、intro.enabled=false 时按 global 启动;顺带修掉一处被本票放大的布局缺陷(见下)。
  • 文档:docs/cockpit-deployment.md 新增 2.10 进区锁定与相机区域限位;ADR 0009 补两处口径。

关键取舍

  1. 限位只在输入时生效(ADR 0009):不做逐帧主动回拉,shown 仍由既有阻尼收敛 —— 手感与票 03/21 一致。
  2. 三处交集只有一个合成点(applyAreaLimits()):视图切换与进区都调它,不各算一套。
  3. 进区时立刻夹一次(clampToLimits()):否则「进区时 range 已在 12 km 以外」要等用户滚一次才被夹 —— 这正是决策票 03 记的 4.7× 突跳来源。
  4. 主场景区不可用时进不去(返回 blocked 原因并如实提示),而不是锁进一个不存在的框。

证据

  • 逻辑闸门 verify/t37-area-limits.mjs 通过:限位解析(区内 max=home.range、管线 min(6000,12000)=6000、 区外回落到全局)、状态转移(只有返回全球能解除;重复 enter 仍锁定 → 判定与相机位置无关; 可用性不足时进不去且带原因)、软区 ≈550 m 与松手回弹落点、防入地只夹 range、顶栏两形态、 闲置 10 秒且区内不计时、以及假引擎驱动的 7 组输入链(含"定位到框外不被夹、紧接着手动拖不瞬移"与 "框内起手仍有软回弹")。
  • 变异测试(守卫有牙)6 处,全部被对应断言打红并已还原: ① 不取交集(上限用全局)→ 上限断言红;② 状态机允许任意动作解除 → 进区断言红; ③ 去掉松手回弹 → 「松手后回到框内」红;④ 抹掉定位豁免 → 「框外起手不得拽回框边」红 (实测把中心从 110.141 拽回 110.323);⑤ 防入地退回字面口径 → 「pivot 在地表应落 120 m 下限」红 (实测 148.4 m,正是会推翻票 30 的那个数);⑥ 把 viewMode 赋值挪到 applyAreaLimits() 之后 → 顺序守卫红。
  • 真机走查(维护者 2026-09-28 要求执行):10 项全部通过。因本机 puppeteer 的 @puppeteer/browsers 是空目录(既有环境缺陷,t28 等同样失败),改用 DSH 自带浏览器驱动同一条验收链, 走真实运行时配置(data-source=file): | # | 用例 | 实测 | | --- | --- | --- | | A1 | 区外自由 | area-state=global、无「返回全球」、按钮「归位/进入」、上限 3×10⁷ | | A2 | 进入系统 → 锁定 | locked、range-max=12000、出现「返回全球」、按钮变「归位」、落到 home(110.390, 28.444) 且在框内 | | A3 | 区内滚轮拉不远 | 先拉近到 120(证明链路通),再连滚 60 次夹在 12000(非全局 3×10⁷) | | A4 | 管线取交集 | min=400, max=6000(min(6000,12000),与现状一致) | | A5 | 顶栏不压模式按钮 | 状态区 512px 装进 521px 列,模式区终于 841 < chip 起 853,0 冲突 | | A6 | 拖到边界回弹 | 拖到 110.47140(软区边)、松手 target 回到 110.465779226663(硬框边);setCameraDamping(0) 同步路径下 shown 同样在框内 | | A7 | 返回全球 | locked→global、上限回 3×10⁷、目标 hpr 精确=heading 0 / pitch −60 / range 2×10⁷、按钮变回 | | A8 | 全球态闲置 | 12 秒无交互后引导层带「点击进入系统」回来 | | A9 | 区内闲置不回引导 | locked 闲置 12 秒,引导层仍为 false | | A10 | 无页面级报错 | enter/return 往返三次,error/unhandledrejection 监听零记录 |
  • 全量回归:23 个零依赖逻辑闸门全绿(t05 守卫面纳入 areaLimits.js,230→233)。 t28-fill-container.mjs 是 puppeteer 脚本,因上述依赖缺失而 ERR_MODULE_NOT_FOUND,与本票无关。
  • 按 AGENTS.md:本会话未跑 build(浏览器走查是维护者本次明确要求才执行)。

实现中抓到的两个真缺陷

  1. applyAreaLimits() 与 viewMode 的赋值顺序错了(我自己写出来又抓到的):先算限位、后落 viewMode,于是切到管线时区域上限仍按地面算(读数 12000 而非 6000)。domain 闸门与静态守卫 都发现不了(它们只测纯函数、只看"有没有调用"),只有真跑一遍才暴露;已修并把"顺序"做成断言(变异 ⑥)。
  2. 顶栏被本票的新 chip 挤爆,压住一级模式按钮:加「返回全球」后右侧状态区内容需 510px、 可用只有 464px,justify-content: flex-end 向左溢出 46px, 实测「归位」(x 855–897)压住「预警处置」(x 799–898)。 修法:状态列由 minmax(240px, 1fr) 改为 minmax(min-content, 1fr)(按内容最小宽度兜底)。 走查加了 A5 按真实几何断言这条,避免以后再插 chip 时悄悄压上去。

边界与遗留

  • 雾气(票 38)不在本票:本票只保证 areaState 与 range_max 是可靠输入。
  • 设备级/点位级定位视角(range 500/150)属平行图 cockpit-point-locate-and-device-link 票 03, 本票只保证它们落在限位内(两档都远低于 12,000 m,任何画布比例下不会被夹)。
  • 本机走查时该浏览器标签 document.hidden===true,rAF 被完全冻结(实测 1 秒 0 帧), 因此"阻尼收敛过程"的断言改用 setCameraDamping(0) 的同步路径验证(同样是既有契约的正常入口); 阻尼本身的手感验收仍由票 03 的真机走查覆盖,本票不重复断言。

下一步

37 就位,38(区内视距雾气)解除阻塞 —— 门控读 getAreaState()(locked 生效、global/引导态关闭), 上限读 getAreaLimits().max(本部署 12,000 m),不要再自己解析主场景区配置。 39(粗→精影像交接)依赖 38;40(全程质量收口)留到最后。