# 44: 设备位置符号与可替换资产接缝 Type: implementation Status: resolved Blocked by: 42 Labels: ready-for-agent ## What to build 按规格 16 落地本期“设备模型”口径:设备位置符号,不加载真三维设备几何模型。建立单一可替换图片/图标素材接缝,临时形状和未来正式图片可替换而不改变设备身份、状态、选中反馈、定位锚点或面板联动;真 glTF/GLB 接入仍归后续独立事项。同时按决策票 07 建立独立 `deviceStatus` 静态词表,设备五态和未知态的颜色、形状、文字不借用监测点聚合状态。监测点与设备保持同风格但通过尺寸层级区分,监测点始终大于设备;默认无长文本,hover/选中显示名称为主、编码为辅。 ## Acceptance - [x] 设备位置符号来自统一资产选择层,地图绘制、图例和详情不散落资源地址或形状字符串。 - [x] 监测点尺寸始终大于设备,动态缩放不反转层级;坐标图标不显示经纬度数字。 - [x] 默认隐藏设备长文本,hover/选中显示有稳定截断/换行边界,不撑破布局。 - [x] 坐标只认设备自身有效坐标与后端准入;地形未就绪/采样失败不上图但清单显示对应原因。 - [x] 正式图片素材未提供或加载失败时单台回退通用位置符号并保留身份/状态/交互,详情标明视觉资源异常;通用符号不是正式资产或真模型。 - [x] 独立设备状态词表表达 `ALARM`/`WARNING`/`WORK_ORDER`/`NORMAL`/`OFFLINE` 和 `UNKNOWN`,设备状态不从 `pointStatus` 推导,状态未知不猜正常;本票只交付静态色/形/文字,动效归 47。 - [x] 正常场景遵守地形和示意体块遮挡,不开启穿透例外;关联设备图例独立于监测点图例。 - [x] 领域断言覆盖资产/地形/坐标降级和尺寸层级;外壳走查验证替换接缝、标签和降级可见性。 ## Out of scope - 真三维模型制作/采购/授权、模型性能数字和实例化方案;设备图层生命周期、双向联动、状态特效和重叠聚合。 ## 进度:100%(2026-09-30 收口) **已完成并关闭。** 领域断言 **47/47**、变异测试 **30/30 全部打红**、**真机走查 11/11(连跑两次稳定)**, 全量零依赖闸门回归绿(3 处红与本票无关,均已核实)。 第 8 条的走查一度被判为"本沙箱不可能"(受限模式下 Chrome 连 `--version` 都因 `crashpad OpenProcess: 拒绝访问 (0x5)` 自杀)。维护者授权提权后实测:**Chrome 能起,走查能跑**, 而且 `document.hidden === false` —— 这比票 41/42/43 当时的条件更好(那几张票的真机环境 `document.hidden === true`,rAF 冻结、Cesium 不渲染,连监测点标记的 `scene.pick` 都拾不到)。 所以本票是一次**真的**走查,不是观感降级。 ### 交付物 - `domain/deviceStatus.js`(新):**独立**设备状态词表。五态(无 `EMPTY`,那是点位聚合态专有) + `UNKNOWN`,每态 `tone`/`shape`/`label`/`priority`;`deviceVisualForStatus()` 认不出的落 `UNKNOWN`(**不猜 NORMAL**);`deviceLegend()` 六行、按后端优先级排、0 不隐藏行。 - `domain/deviceAssets.js`(新):**唯一可替换资产接缝**。`ASSET_MODE`(`image`/`icon`/`fallback`, **刻意没有 `model`**)、`ASSET_REASON`(含 `MODEL_MISSING`/`INVALID`/`FAILED`/`OVER_BUDGET` 四种 真模型降级与 `TERRAIN_PENDING`)、`MARKER_SIZE`(point 18 / device 14 + 缩放夹取)、 `markerSizeFor()`/`sizeHierarchyHolds()`、`deviceLabel()`、`planDeviceAsset()`(**唯一** `markerSvg` 调用点)、`planDevicePlacement()`。 - `domain/deviceLayer.js`:新增 `TERRAIN_PENDING` 档位与文案「地形未就绪 · 位置待确认」, 且判在**坐标有效之后**——「等地图资源」与「数据有问题」是两件事。 - `domain/detailFlow.js`:新增 `deviceStatusView()`,`deviceCardView()` 改走设备表。 **这是修掉的一处既有越界**:设备卡片原先拿**点位**状态表配色配形(决策票 07/10 点名要求分开)。 - `services/cesiumEngine.js`:`syncAssociatedDevices()` 尺寸由调用方传入(不再写死 14), 实体带上 `deviceShape`/`assetMode`/`assetReason`/`assetReasonText`,并**刻意不设** `disableDepthTestDistance`(规格 16:正常遮挡、不开穿透例外);监测点标记保持原有穿透例外 (票 09 的契约,未动)。**读取器 `readAssociatedDevices()` 也补齐**:原先只回设备身份、 把实体上已有的形状/资产模式/降级原因丢掉了 —— 于是走查只能读计划行("打算画什么"), 读不到"画成了什么"。现在连尺寸一起回(尺寸取 **billboard 真实宽高**,不是再读一个属性)。 - `services/mapService.js`:监测点尺寸改走接缝;设备行若已由 store 过完接缝则**只补色键、不重算** (避免覆盖 store 侧同一份结论)。 - `stores/deviceLayerStore.js`:在 store 侧合成资产计划(`shape`/`size`/`assetMode`/`assetReason`/ `visualKey`/`tone`),使**引擎未就绪时清单行也带资产结论**。 - `components/PointDetailCard.vue`:清单行的文本与状态标签分别走 `deviceLabel()` 与 `deviceVisualForStatus()`;新增 `data-device-asset-mode/-reason` 供走查读。 - `verify/cdp-driver.mjs`(新):**零依赖 CDP 驱动**。本机 `puppeteer-core` 已消失, 而 Node 24 自带 `WebSocket`/`fetch`,所以"驱动 Chrome"不需要任何依赖;只实现走查用到的 那一小撮 puppeteer API。**票 45/46/47/48 可直接复用。** - 证据:`verify/t44-device-assets.mjs`(**47/47**)+ 变异 **30/30 全部打红**(两轮:26 + 4)+ `verify/t44-device-assets-walkthrough.mjs` 真机走查 **11/11**(连跑两次稳定)。 ### 关键取舍 1. **接缝只有一个 `markerSvg` 调用点**:形状名与资源地址不许散落在引擎/组件里, `F1` 判"`deviceAssets.js` 里 `markerSvg(` 只出现 1 次",`F4` 判尺寸不写死字面量。 2. **没有 `model` 这个模式**:本期不加载真几何模型,所以模式**取值集合里就没有** `model` (`B7` 正面钉住),而不是"有 model 但不用"。四种真模型不可用各记各的原因,不混成一句话。 3. **地形未就绪不是坐标无效**:前者是"等地图资源"(清单说「位置待确认」), 后者是"数据有问题"(清单说无地图位置)。两档分开判、分开计数、分开显示。 4. **设备状态色/形/文字不借点位表**:两表**平行**。反例断言最有力:同一个字符串 `'EMPTY'` 在设备表落 `UNKNOWN`、在点位表落 `EMPTY` —— 若两表还连着,这两个答案不可能不同。 5. **长文本的"主次"是领域规则、不是模板细节**:`deviceLabel()` 定"名称为主、编码为辅", 组件只负责画。**本票自查发现它一度是死代码**(详见下)。 ### 本票自查抓到并修掉的 3 处真缺陷 1. **资产计划只在服务层算 ⇒ 引擎未就绪时清单行没有资产字段**(G 段抓到)。 后果是详情永远说不出"视觉资源异常",而这正是验收第 5 条要的。修法:在 store 侧合成计划, 服务层遇到已算过的行只补色键。已补 `G2`/`M18` 钉住。 2. **`deviceLabel()` 是死代码 ⇒ 验收第 3 条的"hover/选中显示名称为主、编码为辅"没有落点**。 自查时全仓 `grep deviceLabel` 只有定义、没有任何生产调用点;而设备清单行当时是 **编码在前、名称在后**(`编码为主`),与规格顺序相反。修法:清单行文本改由 `deviceLabel(row, { visible: true })` 驱动(名称占可伸缩列并承担截断),并补 `F12` 守卫 —— 判据不是"文件里出现过 `deviceLabel`"(那会被 `import` 语句满足),而是**模板真的用了它的结论**。 3. **引擎实体明明带着接缝结论,读取器却把它们吞掉**。实体上有 `deviceShape`/`assetMode`/ `assetReason`,但 `readAssociatedDevices()` 只回 `deviceCode`/`monitoringPointCode`/ `deviceStatus` —— 结果是"地图上真画的那台设备是什么形状、什么资产模式"**在引擎侧读不到**, 走查被迫退回去读计划行("打算画什么"≠"画成了什么")。修法:读取器补齐形状/尺寸/资产模式/原因 (尺寸取 billboard 真实宽高),并补 `F13`(实体**写入**结论)与 `F14`(读取器**暴露**结论)两条守卫。 这条是**为了给验收第 8 条留下真正可读的引擎侧证据**才发现的 —— 走查写不出来的判据,往往正说明 生产代码少了一个观测面。 ### 证据 - **领域断言 47/47**(`verify/t44-device-assets.mjs`): A 独立词表(8)· B 接缝与降级链(7)· C 尺寸层级(4)· D 标签(4)· E 地形 vs 坐标(4)· F 静态守卫(14)· G store 级端到端(5)。 - **变异测试 30/30 全部打红**(两轮:26 条主体 + 4 条针对新增的 `F13`/`F14`): 每条守卫都有一条变异证明它有牙。其中 `M21`(把 `TERRAIN_PENDING` 混成 `NO_LOCATION`) 的判据在**票 42 的门**里,所以变异脚本必须两门都跑 —— 这点本票栽过一次(见下「一个方法论教训」)。 - **store 级端到端(G 段)**:用 pinia 驱动 `deviceLayerStore.syncForSelection()` 这条**真实路径**, 断言两台设备(一台可上图、一台后端判不可上图)产出的行确实带资产模式/原因/尺寸/视觉键, 且不可上图那台**留在清单里但没进绘制**。 - **静态守卫里最该看的几条**:`F3` 只看 `disableDepthTestDistance:`(**带冒号**)—— 否则注释里解释"故意不设"的那句话会把守卫满足掉,那是"守卫比它要守的东西宽"; `F6` 只截 `deviceCardView` 的函数体 —— 全文匹配会被 `pointSummary` 正确地用点位表这一事实满足。 ### 一个方法论教训(已修) 变异脚本最初**只跑 t44 一道门**,而 `M21` 的判据 `C8` 在 t42。结果 `M21` 报"假存活", 看起来像守卫没牙,实际是变异落在**没跑的那道门**里、根本没人看。修法:变异脚本对每处变异 **两门都跑**,谁逮到都算。**结论:报"未被逮到"之前,先确认那道断言真的在跑。** ### 未覆盖项(如实记录) 1. **真机视觉观感**(符号大小对比、截断是否美观、动效缺失时的观感)属主观项, 脚本只判可断言量(尺寸/矩形/计算样式),不判"好不好看"。 2. **`deviceStatus.js` 的 `deviceLegend()` 目前没有生产调用点**(自查发现,与本票修掉的 `deviceLabel` 同类)。清单里**每台设备**的 `StatusTag` 已经把它自己的形状/色键/文字显示出来了, 而地图图层那个图例是**放置档位**图例(`associatedLegend`,7 类)—— 所以"设备状态图例"这个 面目前是空的。验收第 6/7 条**不要求**渲染它(状态含义在逐台上已可见,图层图例也已独立于点位图例), 故本票**不动它**,但这个决定要留给票 47 明确:要么在状态特效那一版把它渲染出来, 要么删掉这个域助手(现在它只是被 t44 的 `A6` 单测着、没有界面用它)。 留一条"被测着但没接线"的助手,正是本票前两处真缺陷的同一个成因,所以在这里点名。 ### 回归 **零依赖逻辑闸门**(跑过 35 道):t05 **276**/t07 **39**/t09 **38**/t10 **57**/t11 **61**/t12 **52**/ t17 **57**/t18 **71**/t41/t42 **39**/t43/t44 **47** 等全绿。 **三处红与本票无关**:① `t28-fill-container.mjs` 需 puppeteer(模块已不在本机); ② `t04-config.mjs` 的「页面与组件不该硬编码后端地址」指向 `services/cockpitApi.js`; ③ `t37-area-limits.mjs` 的「满 5 秒触发」指向 `domain/areaLimits.js`(13:45 被改过, 晚于本票最后一次 `cesiumEngine.js` 编辑 13:41)。②③ 都是**并发会话正在改**的文件, 均不在本票改动清单里(`git status` 与文件时间可核)。 **关于 `AGENTS.md` 的 build/dev 限制**:本会话**没有**跑 build; `yarn dev` 也**没有**由我启动 —— 8000 端口本来就已有 HTTP 200 在服务(维护者环境里已经起着)。 浏览器只用于维护者当轮明确授权的这次走查。 ### 真机走查(已执行,11/11,连跑两次稳定) **环境结论先说**:`puppeteer-core` 已从 `~/.dsh/profiles/desktop/node_modules` 消失 (`t28-fill-container.mjs` 因此 `ERR_MODULE_NOT_FOUND`),所以本票写了**零依赖 CDP 驱动** `verify/cdp-driver.mjs` —— 用 Node 24 自带的 `WebSocket`/`fetch` 直接讲 CDP, 只实现走查用到的那一小撮 puppeteer API。**票 45/46/47/48 可直接复用**。 另:Chrome 启动需要放宽沙箱(受限模式下 crashpad `OpenProcess: 拒绝访问 (0x5)` 自杀), 即使只是 `--version`。 **两趟走查,各自针对不同的边界**: - **第一趟 `device-layer` 样例(受控有效坐标)**:`W1` 清单 2 行真的渲染出来(总数读数 2/已上图 2)· `W2` 清单 DOM 的资产模式/原因与**地图实体**逐台相同(`icon/asset-missing` 两边一致 —— "地图画的是A、面板说的是B"这种分裂被排除)· `W3` 每行都带资产原因文案「正式图片素材未提供, 当前为设备专属图标」· `W4` 监测点标记图片宽 **18px**、设备实体真实尺寸 **14/14**(层级在 引擎 billboard 上成立)· `W5` 计算样式 `min-width:0 / overflow:hidden / white-space:nowrap / text-overflow:ellipsis` 全部生效,且清单 `scrollWidth 316 <= clientWidth 316`(不撑破布局)· `W7` 图层图例 7 行放置档位 vs 点位图例 2 行状态键,**交集 0** · `W8` 无页面报错。 - **第二趟默认样例(照抄现网正式边界)**:`W6` 清单 2 行**都是降级**且都没上图 —— `EQ-001(no-location)「无地图位置」`、`EQ-002(inconsistent)「与当前快照不一致」`; `W9` 引擎实体数 **0**(现网无可信校核源 ⇒ 真实关联设备统一不上图,只在清单里说明原因, 照票 42 验收第 2 条"样例不得冒称正式已能上图")· `W10` 降级原因在详情里可见 · `W11` 无页面报错。 **这趟走查自己也被抓出两处写法问题(已修,值得记)**: 1. `W7` 初版断言"设备图例 6 行、含 `UNKNOWN`" —— 真机跑出 **7 行**才暴露: `[data-device-layer-legend]` 渲染的是**放置档位**图例,不是设备**状态**词表。 我按想象的界面写了断言。修法:改成判"两张图例的词表交集为 0"(真正的不独立判据)。 2. `W6` 初版写成 `blockedRows.length === 0 ? true : …` —— 第一趟两台设备坐标都有效, 于是它**恒真空转**,却显示 PASS。这正是本仓反复强调的"守卫比它要守的东西宽"。 修法:降级可见性另跑一趟**默认样例**,并要求"降级行确实存在"。 **教训:一条会 PASS 的空转断言比一条 FAIL 更危险 —— 它把"没验"伪装成"验过了"。** ### 与下游票的边界(不在本票实现) - **票 45**:重叠设备的聚合符号、展开环、逐台拾取。本票只做正常单台的符号与资产。 - **票 46**:完整四模式图层矩阵。本票不动模式可见性(票 42 的 `isVisibleInMode` 继续生效)。 - **票 47**:状态特效(报警扩散脉冲/闪烁/旋转环)。本票**刻意只交付静态**色/形/文字, 并有 `F11` 钉住"词条只含静态描述"(判据是**键的集合**,不是"有没有出现动画字样"—— 后者加个 `effect: 'pulse'` 仍会全绿)。 ### 下一步 1. **票 47 请明确 `deviceLegend()` 的去留**(渲染成状态图例,还是删掉这个未被接线的域助手)—— 见「未覆盖项 2」。本票刻意不替它决定,因为那属"状态表达"那一版的面。 2. `45` 需 42/43/44/46 全绿(43/44 已 resolved,还差 46);`47` 需 38/44/45/46;`48` 需 40…47 全部。 本票已 resolved,不再是下游的阻塞项。