|
|
@@ -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) {
|