Bläddra i källkod

fix(cockpit): 修复坐标点先入地再上浮的贴地投影

ChenJZ 1 dag sedan
förälder
incheckning
c93b5c3d17

+ 40 - 0
src/modules/cockpit/domain/groundAnchor.js

@@ -0,0 +1,40 @@
+/**
+ * 标记贴地高度规则(维护者 2026-11-11「坐标点先入地、再上浮到地表」)。
+ *
+ * Cesium 的 `Globe.getHeight` 取的是**当前已渲染网格**的高度:细瓦片网格没出来时给的是父级
+ * 粗网格(粗→细可差几十米),连粗网格都没有才回 `undefined`。所以贴地高度在地形补齐过程中
+ * 本来就会变。老实现把它简化成两条兜底,正是缺陷来源:
+ *   · 取不到就按椭球面 0 米画 —— 地形区里标记被摆到地面以下;
+ *   · 取到就缓存 3 秒 —— 地表抬高后标记仍停在旧高度。
+ * 两者叠加就是用户看到的「先入地、再上浮」。
+ *
+ * 标记层口径(让位/锚定那条路仍按旧口径回椭球面,见 `projectPointMarker`):
+ *   有样本 > 最后有效高度 > (加载中:不上图)> (加载已停:无地形覆盖,椭球面即真实地面)。
+ */
+
+/**
+ * 纯规则:一次采样该用哪个高度。
+ * @param {{sample?: number, lastKnown?: number, terrainLoading?: boolean|Function}} input
+ *   `terrainLoading` 可以是惰性函数 —— `globe.tilesLoaded` 是 getter,只有真要判断时才读。
+ * @returns {number|null} 米;`null` = 没有可信地面,调用方不上图
+ */
+export function resolveGroundHeight({ sample, lastKnown, terrainLoading } = {}) {
+    if (Number.isFinite(sample)) return sample
+    if (Number.isFinite(lastKnown)) return lastKnown
+    const loading = typeof terrainLoading === 'function' ? terrainLoading() : terrainLoading
+    return loading === true ? null : 0
+}
+
+/** 按坐标记住「最后一次有效地面高度」(只记有效值:记下"没取到"等于把假值钉住)。 */
+export function createGroundAnchor() {
+    const lastKnown = new Map()
+    return {
+        remember(key, sample) {
+            if (Number.isFinite(sample)) lastKnown.set(key, sample)
+        },
+        lastKnown(key) {
+            const value = lastKnown.get(key)
+            return Number.isFinite(value) ? value : null
+        },
+    }
+}

+ 4 - 2
src/modules/cockpit/domain/heightCache.js

@@ -8,8 +8,10 @@
  * 每帧都会对**每个标记**重查一遍 —— 几百个标记就是几百次四叉树下降,
  * 这是近景拖动时主线程的主要开销之一。
  *
- * 实体投影那条路径早就按实体缓存了 3 秒(见 `projectMarkerEntity` 的注释);
- * 这里把同一口径用到"按坐标"的调用上,两条路径的语义就一致了。
+ * 实体投影那条路径也吃这同一份缓存(`projectMarkerEntity` 按坐标取),两条路径语义一致。
+ *
+ * 除了 TTL,引擎还在 `globe.tileLoadProgressEvent` 上挂了一层失效(瓦片落地就清):
+ * 否则标记会在新地表以下停到 TTL 到期(维护者 2026-11-11 报的「地底→上浮」)。
  *
  * ## 为什么用 TTL 而不是永久缓存
  *

+ 120 - 31
src/modules/cockpit/services/cesiumEngine.js

@@ -136,9 +136,6 @@ export function disposeSceneBloom(viewer) {
  */
 const projectionCache = { rect: null, rectAt: 0, occluder: null, occluderKey: '', occluderEllipsoid: null, toward: new Cartesian3() }
 
-/** 实体 → 上一次查到的地面高程(静态位置,3 秒保鲜;瓦片补齐后自然更新)。 */
-const entityGroundHeightCache = new WeakMap()
-
 function canvasRectCached(viewer) {
     const now = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
     if (!projectionCache.rect || now - projectionCache.rectAt > 120) {
@@ -237,6 +234,10 @@ export function createViewer(host, basemapPlan) {
     // 地面视图必须保留地形深度,否则埋在地表以下的 3D Tiles 也会覆盖到地形之上。
     // 回归:tools/city-massing/verify-occlusion.mjs 用实际渲染像素检查遮挡。
     viewer.scene.globe.depthTestAgainstTerrain = true
+    // 贴地高度会随地形补齐而变(`Globe.getHeight` 取的是**当前已渲染网格**的高度):
+    // 采样缓存必须跟着失效,否则 DOM 标记会停在地表以下直到 TTL 到期 ——
+    // 维护者 2026-11-11 报的「坐标点先入地、再上浮到地表」正是这段滞后。
+    attachGroundHeightRefresh(viewer)
     return viewer
 }
 
@@ -313,6 +314,7 @@ export const PERFORMANCE_DEFAULT_OPTIONS = { ...PERFORMANCE_DEFAULTS }
 import { isContinuousGap } from '../domain/adaptiveResolution.js'
 import { createRenderResolutionController } from '../domain/renderResolutionController.js'
 import { createHeightCache, heightKey } from '../domain/heightCache.js'
+import { createGroundAnchor, resolveGroundHeight } from '../domain/groundAnchor.js'
 
 // Per-viewer state avoids inheriting samples/targets from a destroyed or different viewer.
 const resolutionControllers = new WeakMap()
@@ -854,6 +856,7 @@ export function applyViewModeLayers(viewer, tilesets, plan) {
 export function destroyViewer(viewer) {
     if (!viewer) return
     resolutionControllers.delete(viewer)
+    detachGroundHeightRefresh(viewer)
     if (!viewer.isDestroyed()) viewer.destroy()
 }
 
@@ -1787,7 +1790,9 @@ export function projectDeviceMarkers(viewer) {
         if (!code || entity.show === false) continue
         // projectMarkerEntity 已含画布原点(窗口坐标)与 pixelOffset(尖端落点)——直接用,
         // 调用方只减宿主原点(与 projectToWindow 的口径只差 host 一层)。
-        const projected = projectMarkerEntity(viewer, entity)
+        // 渲染口径:地形还没有可信地面时回 null,该设备本轮不上图
+        // (不按椭球面画到地底;见 `markerGroundHeight`)。
+        const projected = projectMarkerEntity(viewer, entity, markerGroundHeightAt)
         if (!projected) continue
         const color = entity.billboard && entity.billboard.color ? entity.billboard.color.getValue() : null
         // 本设备是否是当前组的中心(= 聚合点已被它替换)。
@@ -1860,7 +1865,8 @@ export function projectOverlapSymbols(viewer) {
         const properties = entity.properties || {}
         const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
         if (read('overlapAggregate') !== true || entity.isShowing !== true) continue
-        const projected = projectMarkerEntity(viewer, entity)
+        // 渲染口径同上:没有可信地面就不画聚合符号,不摆椭球假位置。
+        const projected = projectMarkerEntity(viewer, entity, markerGroundHeightAt)
         if (!projected) continue
         out.push({
             key: `overlap|${read('overlapPointCode')}|${read('overlapGroup')}`,
@@ -1953,7 +1959,7 @@ export function readOverlapTargets(viewer) {
     return source.entities.values
         .filter((entity) => entity.properties && entity.properties.overlapGroup)
         .map((entity) => {
-            const projected = projectMarkerEntity(viewer, entity)
+            const projected = projectMarkerEntity(viewer, entity, anchorGroundHeightAt)
             if (!projected) return null
             const properties = entity.properties || {}
             const get = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : ''
@@ -2344,25 +2350,118 @@ export function pipelineSource(viewer) {
     return viewer.dataSources.getByName(PIPELINE_SOURCE)[0] || null
 }
 
-/** Currently loaded terrain height; null means no reliable surface sample yet. */
-/** 按坐标的地面高程缓存(票 98 第二轮):DOM 标记层每帧对每个标记都要取一次高程。 */
+/** 按坐标的地面高程缓存(票 98 第二轮):标记/锚定两条投影每帧都要取一次高程。 */
 const groundHeightCache = createHeightCache({ ttlMs: 3000, maxEntries: 5000 })
 
+/** 地形瓦片加载状态变化的监听器(卸载时摘掉;否则打向已销毁的 scene)。 */
+const groundRefreshDetachers = new WeakMap()
+
+/** 标记层「最后一次有效地面高度」:每 viewer 一份,跟随 viewer 生命周期。 */
+const groundAnchors = new WeakMap()
+function groundAnchorFor(viewer) {
+    let anchor = groundAnchors.get(viewer)
+    if (!anchor) {
+        anchor = createGroundAnchor()
+        groundAnchors.set(viewer, anchor)
+    }
+    return anchor
+}
+
+function nowMs() {
+    return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
+}
+
+/** 取这一刻的可信地面样本;没有网格(瓦片未落地/无覆盖)回 null,且**不进缓存**。 */
+function sampleGroundHeightAt(globe, carto, key) {
+    const height = groundHeightCache.read(key, nowMs(), () => {
+        const sampled = globe.getHeight(carto)
+        return Number.isFinite(sampled) ? sampled : null
+    })
+    return Number.isFinite(height) ? height : null
+}
+
+/**
+ * 地形瓦片一有落地就丢掉高程采样缓存。
+ *
+ * `Globe.getHeight` 给的是**当前已渲染网格**的高度,粗网格 → 细网格可以差几十米;
+ * 缓存不失效,标记就会在新地表以下停留到 TTL(3 秒)到期——用户看到的「地底→上浮」。
+ * 导出让闸门能拿假 viewer 直接验这条契约(与 `groundHeightAt` 同做法)。
+ */
+export function attachGroundHeightRefresh(viewer) {
+    const globe = viewer && viewer.scene && viewer.scene.globe
+    const event = globe && globe.tileLoadProgressEvent
+    if (!event || typeof event.addEventListener !== 'function') return
+    let lastAt = 0
+    const remove = event.addEventListener((remaining) => {
+        const now = nowMs()
+        // 队列见底=本轮瓦片全落地,必丢;加载中节流丢(每来一片就重采几百个坐标是纯浪费)。
+        if (remaining > 0 && now - lastAt < 250) return
+        lastAt = now
+        groundHeightCache.clear()
+    })
+    if (typeof remove === 'function') groundRefreshDetachers.set(viewer, remove)
+}
+
+/** 摘掉高程刷新监听(`destroyViewer` 调用;同时丢掉该 viewer 的记忆表)。 */
+export function detachGroundHeightRefresh(viewer) {
+    const remove = groundRefreshDetachers.get(viewer)
+    if (typeof remove === 'function') {
+        try {
+            remove()
+        } catch {
+            // 场景已销毁:忽略
+        }
+    }
+    groundRefreshDetachers.delete(viewer)
+    groundAnchors.delete(viewer)
+}
+
+/** 让位/锚定口径的地面高程:无样本按椭球面 0 米(票 41/47 的既定判据,勿收紧)。 */
 export function groundHeightAt(viewer, lon, lat) {
     const globe = viewer && viewer.scene && viewer.scene.globe
     if (!globe || !Number.isFinite(lon) || !Number.isFinite(lat)) return null
-    // 标记是静态的,相机一动却会每帧对每个标记重查一次四叉树 —— 纯重复。
-    // 与 projectMarkerEntity 的 3 秒按实体缓存同一口径(地形补齐后自动刷新)。
-    const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
-    const height = groundHeightCache.read(heightKey(lon, lat), nowMs, () => {
-        const sampled = globe.getHeight(Cartographic.fromDegrees(lon, lat))
-        return Number.isFinite(sampled) ? sampled : null
+    return sampleGroundHeightAt(globe, Cartographic.fromDegrees(lon, lat), heightKey(lon, lat))
+}
+
+/**
+ * **标记层**(DOM 覆盖层)的贴地高度:没有可信地面时回 `null`=调用方不上图。
+ *
+ * 口径在 `domain/groundAnchor.js`:有样本 > 最后有效高度 > (加载中:不上图)>
+ * (加载已停:无地形覆盖,椭球面即真实地面)。让位/锚定那一路不走这里。
+ * @returns {number|null} 米
+ */
+export function markerGroundHeight(viewer, lon, lat) {
+    const globe = viewer && viewer.scene && viewer.scene.globe
+    if (!globe || !Number.isFinite(lon) || !Number.isFinite(lat)) return null
+    return markerGroundHeightAt(viewer, globe, Cartographic.fromDegrees(lon, lat))
+}
+
+function markerGroundHeightAt(viewer, globe, carto) {
+    const key = heightKey(CesiumMath.toDegrees(carto.longitude), CesiumMath.toDegrees(carto.latitude))
+    const sample = sampleGroundHeightAt(globe, carto, key)
+    const anchor = groundAnchorFor(viewer)
+    anchor.remember(key, sample)
+    // `terrainLoading` 传函数:`tilesLoaded` 是 getter,只有真需要判断时才读。
+    return resolveGroundHeight({
+        sample,
+        lastKnown: anchor.lastKnown(key),
+        terrainLoading: () => globe.tilesLoaded !== true,
     })
-    return Number.isFinite(height) ? height : null
 }
 
-/** 贴地投影与屏幕分离共用一份口径,返回视觉中心及当前图标尺寸。 */
-function projectMarkerEntity(viewer, entity) {
+/** 让位/锚定口径的高度解算器(`projectMarkerEntity` 的参数)。 */
+function anchorGroundHeightAt(viewer, globe, carto) {
+    const sampled = sampleGroundHeightAt(globe, carto, heightKey(CesiumMath.toDegrees(carto.longitude), CesiumMath.toDegrees(carto.latitude)))
+    return Number.isFinite(sampled) ? sampled : 0
+}
+
+/** 贴地投影与屏幕分离共用一份口径,返回视觉中心及当前图标尺寸。
+ *
+ * `groundOf` 决定「没有可信地面时怎么办」,由调用方显式给(两个消费方的要求相反):
+ *   · `markerGroundHeightAt`(DOM 标记层):回 null=不上图,不按椭球面摆假位置;
+ *   · `anchorGroundHeightAt`(让位/锚定/走查读数):回椭球面 0 米,判据不能比可见性更严。
+ */
+function projectMarkerEntity(viewer, entity, groundOf) {
     if (!viewer || !viewer.scene || !viewer.canvas || !entity || !entity.isShowing || !entity.position || !entity.billboard) return null
     const original = entity.position.getValue(viewer.clock.currentTime)
     if (!original) return null
@@ -2370,18 +2469,8 @@ function projectMarkerEntity(viewer, entity) {
     const carto = ellipsoid.cartesianToCartographic(original)
     if (!carto) return null
 
-    // 地面高程:实体位置是静态的,地形瓦片也只会逐步补齐;每帧给每个实体查一次
-    // `globe.getHeight`(四叉树下降)是纯重复。按实体缓存 3 秒,瓦片补齐后自然刷新。
-    const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
-    let height = 0
-    const cachedHeight = entityGroundHeightCache.get(entity)
-    if (cachedHeight && nowMs - cachedHeight.at < 3000) {
-        height = cachedHeight.height
-    } else {
-        const sampled = viewer.scene.globe.getHeight(carto)
-        height = Number.isFinite(sampled) ? sampled : 0
-        entityGroundHeightCache.set(entity, { height, at: nowMs })
-    }
+    const height = groundOf(viewer, viewer.scene.globe, carto)
+    if (height === null) return null
 
     const world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height)
     const camera = viewer.camera
@@ -2427,7 +2516,7 @@ export function projectPointMarker(viewer, kind, code) {
     const source = markerSources.get(viewer) || viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
     if (!source || !source.show) return null
     const entity = source.entities.getById(`point|${kind}|${code}`)
-    return projectMarkerEntity(viewer, entity)
+    return projectMarkerEntity(viewer, entity, anchorGroundHeightAt)
 }
 
 /** 关联设备锚向实际绘制的设备实体,不回退到父监测点。 */
@@ -2436,7 +2525,7 @@ export function hasDeviceMarker(viewer, code, pointCode = '') {
 }
 
 export function projectDeviceMarker(viewer, code, pointCode = '') {
-    return projectMarkerEntity(viewer, findDeviceMarker(viewer, code, pointCode))
+    return projectMarkerEntity(viewer, findDeviceMarker(viewer, code, pointCode), anchorGroundHeightAt)
 }
 
 function findDeviceMarker(viewer, code, pointCode) {

+ 11 - 5
src/modules/cockpit/services/mapService.js

@@ -1080,11 +1080,18 @@ async function bootEngine(record) {
 
         // 票 03:相机契约落在服务层 —— 引擎就绪后才挂,默认输入已在 createViewer 里全关。
         const cameraEngine = engine.createCameraEngine(viewer)
-        // 设施锚点和 DOM 标记同帧共用地形投影,不读上一帧节点位置。
+        // 设施锚点/面板让位的投影:无样本回椭球面 0 米(判据不能比「可见性」更严,票 41/47)。
         record.projectMapPoint = (lon, lat) => {
             const ground = engine.groundHeightAt(viewer, lon, lat)
             return cameraEngine.projectPivot({ lon, lat, height: Number.isFinite(ground) ? ground : 0 }, { visibleOnly: true })
         }
+        // DOM 标记层的投影(维护者 2026-11-11):没有可信地面就回 null=**不上图**,
+        // 绝不按椭球面把标记摆到地面以下(口径见 domain/groundAnchor.js)。
+        record.projectMarkerPoint = (lon, lat) => {
+            const height = engine.markerGroundHeight(viewer, lon, lat)
+            if (height === null) return null
+            return cameraEngine.projectPivot({ lon, lat, height }, { visibleOnly: true })
+        }
         record.cameraControl = createCameraControl({
             engine: cameraEngine,
             onStateChange: (state) => {
@@ -1344,11 +1351,10 @@ async function bootEngine(record) {
                     facilityEffectOf: overlayFrameCache.facilityEffectOf,
                     converge: overlayFrameCache.converge,
                     planOf: overlayPlanMarkerCached,
-                    // 贴地定位与 billboard `CLAMP_TO_GROUND` 同口径(票 47 维护者反馈①根因):
-                    // 首版按椭球 0 米投影,地形区里 DOM 标记恒落在 billboard 标记的**地面以下**。
-                    // 高度取值与 `projectMarkerEntity` 一致:地形瓦片实测高程,取不到回 0(椭球面)。
+                    // DOM 标记层贴地(维护者 2026-11-11):没有可信地面就回 null,
+                    // overlay 会把该节点隐藏=**不上图**,而不是先画在地底再上浮。
                     projectToWindow: (lon, lat) => {
-                        const at = record.projectMapPoint(lon, lat)
+                        const at = record.projectMarkerPoint(lon, lat)
                         if (!at) return null
                         // projectPivot 回**窗口坐标**;overlay 宿主 inset:0 铺在 host 内,
                         // 节点坐标原点 = host 左上角 → 减 host 的窗口原点(每帧只读一次布局)。

+ 2 - 1
src/modules/cockpit/services/markerOverlay.js

@@ -11,7 +11,8 @@
  *     `recolorInner` 去色重染规则,但**不下采样**——256px 原生分辨率 data URL,
  *     DOM 里按帽径向下采样,永远锐利)。
  *   · 标签 = 真 DOM(圆角底板、装饰边、text-overflow、hover title 原生能力)。
- *   · 每帧 `postRender` 投影:与 billboard `CLAMP_TO_GROUND` 同口径(地形实测高程,取不到回椭球 0)。
+ *   · 每帧 `postRender` 投影:贴**当前已渲染的地形网格**高度;没有可信地面时本轮不上图
+ *     (不按椭球面 0 米摆到地底),口径见 `domain/groundAnchor.js`。
  *
  * 与既有链路的关系(维护者裁定:DOM 完整复刻 billboard 功能后才替换它):
  *   · 合成计划层 `domain/markerPlan.js` **原样复用**(归属/几何/锚点/降饱和不换媒介);