|
@@ -234,10 +234,6 @@ export function createViewer(host, basemapPlan) {
|
|
|
// 地面视图必须保留地形深度,否则埋在地表以下的 3D Tiles 也会覆盖到地形之上。
|
|
// 地面视图必须保留地形深度,否则埋在地表以下的 3D Tiles 也会覆盖到地形之上。
|
|
|
// 回归:tools/city-massing/verify-occlusion.mjs 用实际渲染像素检查遮挡。
|
|
// 回归:tools/city-massing/verify-occlusion.mjs 用实际渲染像素检查遮挡。
|
|
|
viewer.scene.globe.depthTestAgainstTerrain = true
|
|
viewer.scene.globe.depthTestAgainstTerrain = true
|
|
|
- // 贴地高度会随地形补齐而变(`Globe.getHeight` 取的是**当前已渲染网格**的高度):
|
|
|
|
|
- // 采样缓存必须跟着失效,否则 DOM 标记会停在地表以下直到 TTL 到期 ——
|
|
|
|
|
- // 维护者 2026-11-11 报的「坐标点先入地、再上浮到地表」正是这段滞后。
|
|
|
|
|
- attachGroundHeightRefresh(viewer)
|
|
|
|
|
return viewer
|
|
return viewer
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -313,8 +309,7 @@ export const PERFORMANCE_DEFAULT_OPTIONS = { ...PERFORMANCE_DEFAULTS }
|
|
|
|
|
|
|
|
import { isContinuousGap } from '../domain/adaptiveResolution.js'
|
|
import { isContinuousGap } from '../domain/adaptiveResolution.js'
|
|
|
import { createRenderResolutionController } from '../domain/renderResolutionController.js'
|
|
import { createRenderResolutionController } from '../domain/renderResolutionController.js'
|
|
|
-import { createHeightCache, heightKey } from '../domain/heightCache.js'
|
|
|
|
|
-import { createGroundAnchor, resolveGroundHeight } from '../domain/groundAnchor.js'
|
|
|
|
|
|
|
+import { heightKey } from '../domain/heightCache.js'
|
|
|
|
|
|
|
|
// Per-viewer state avoids inheriting samples/targets from a destroyed or different viewer.
|
|
// Per-viewer state avoids inheriting samples/targets from a destroyed or different viewer.
|
|
|
const resolutionControllers = new WeakMap()
|
|
const resolutionControllers = new WeakMap()
|
|
@@ -2350,108 +2345,145 @@ export function pipelineSource(viewer) {
|
|
|
return viewer.dataSources.getByName(PIPELINE_SOURCE)[0] || null
|
|
return viewer.dataSources.getByName(PIPELINE_SOURCE)[0] || null
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 按坐标的地面高程缓存(票 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)
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 地面高程的**一次性采样表**(维护者 2026-11-11「还是会出现位置跳转」)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 为什么不能用 `globe.getHeight` 逐帧取:它返回的是**当前已渲染网格**的高度,同一坐标在粗网格与
|
|
|
|
|
+ * 细网格之间能差几十米。镜头一动 LOD 就换,标记就跟着跳——这是「靠近后又上浮」的根源;
|
|
|
|
|
+ * 让缓存跟着瓦片失效只能减少次数,消除不了。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 所以标记/锚定一律用 `sampleTerrainMostDetailed` 按坐标采一次**最详细**高程并**永不重采**:
|
|
|
|
|
+ * 同一坐标永远得到同一个值,位置就不可能再跳。
|
|
|
|
|
+ */
|
|
|
|
|
+const detailedGroundHeights = new WeakMap()
|
|
|
|
|
+/** 单批采样上限:批太大请求会被拆得很零碎(与 `cesiumPipeModels` 同量级)。 */
|
|
|
|
|
+const GROUND_SAMPLE_CHUNK = 256
|
|
|
|
|
+
|
|
|
|
|
+/** 某 viewer 的高程采样器:`heights` 已定值(永不重采),`pending` 正在采的坐标键。 */
|
|
|
|
|
+function groundSamplerFor(viewer) {
|
|
|
|
|
+ let sampler = detailedGroundHeights.get(viewer)
|
|
|
|
|
+ if (!sampler) {
|
|
|
|
|
+ sampler = { heights: new Map(), pending: new Set() }
|
|
|
|
|
+ detailedGroundHeights.set(viewer, sampler)
|
|
|
}
|
|
}
|
|
|
- return anchor
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function nowMs() {
|
|
|
|
|
- return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
|
|
|
|
|
|
|
+ return sampler
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 已定值就直接用;没采过就先定值(无地形)或返回 null 并排一次异步采样。 */
|
|
|
|
|
+function queuedGroundHeight(viewer, lon, lat) {
|
|
|
|
|
+ if (!viewer || !Number.isFinite(lon) || !Number.isFinite(lat)) return null
|
|
|
|
|
+ const sampler = groundSamplerFor(viewer)
|
|
|
|
|
+ const key = heightKey(lon, lat)
|
|
|
|
|
+ if (sampler.heights.has(key)) return sampler.heights.get(key)
|
|
|
|
|
+ if (!viewer.terrainProvider) {
|
|
|
|
|
+ // 无地形部署:椭球面就是地面,不必等一拍异步。
|
|
|
|
|
+ sampler.heights.set(key, 0)
|
|
|
|
|
+ return 0
|
|
|
|
|
+ }
|
|
|
|
|
+ queueGroundSample(viewer, sampler, [{ key, lon, lat }])
|
|
|
|
|
+ return null
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 取这一刻的可信地面样本;没有网格(瓦片未落地/无覆盖)回 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
|
|
|
|
|
|
|
+/** 排一批地形采样:无地形 provider 时直接定 0(椭球面就是地面),否则采一次并永久记住。 */
|
|
|
|
|
+function queueGroundSample(viewer, sampler, items) {
|
|
|
|
|
+ const provider = viewer && viewer.terrainProvider
|
|
|
|
|
+ if (!provider) {
|
|
|
|
|
+ for (const item of items) sampler.heights.set(item.key, 0)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const batch = []
|
|
|
|
|
+ for (const item of items) {
|
|
|
|
|
+ if (sampler.heights.has(item.key) || sampler.pending.has(item.key)) continue
|
|
|
|
|
+ sampler.pending.add(item.key)
|
|
|
|
|
+ batch.push(item)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!batch.length) return
|
|
|
|
|
+ Promise.resolve()
|
|
|
|
|
+ .then(async () => {
|
|
|
|
|
+ for (let start = 0; start < batch.length; start += GROUND_SAMPLE_CHUNK) {
|
|
|
|
|
+ const chunk = batch.slice(start, start + GROUND_SAMPLE_CHUNK)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const sampled = await sampleTerrainMostDetailed(
|
|
|
|
|
+ provider,
|
|
|
|
|
+ chunk.map((item) => Cartographic.fromDegrees(item.lon, item.lat)),
|
|
|
|
|
+ )
|
|
|
|
|
+ chunk.forEach((item, index) => {
|
|
|
|
|
+ const height = sampled && sampled[index] && sampled[index].height
|
|
|
|
|
+ // 只有真采到才入表;失败的坐标下次还会再试,不会被钉成 0。
|
|
|
|
|
+ if (Number.isFinite(height)) sampler.heights.set(item.key, height)
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // 整批失败:不写表,等下一帧再排。
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ for (const item of chunk) sampler.pending.delete(item.key)
|
|
|
|
|
+ const scene = viewer && viewer.scene
|
|
|
|
|
+ if (scene && typeof scene.requestRender === 'function') scene.requestRender()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(() => {})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 地形瓦片一有落地就丢掉高程采样缓存。
|
|
|
|
|
|
|
+ * 按坐标取**确定性**地面高度:已采过的直接用(永不重采),没采过的返回 `null` 并排队采样。
|
|
|
*
|
|
*
|
|
|
- * `Globe.getHeight` 给的是**当前已渲染网格**的高度,粗网格 → 细网格可以差几十米;
|
|
|
|
|
- * 缓存不失效,标记就会在新地表以下停留到 TTL(3 秒)到期——用户看到的「地底→上浮」。
|
|
|
|
|
- * 导出让闸门能拿假 viewer 直接验这条契约(与 `groundHeightAt` 同做法)。
|
|
|
|
|
|
|
+ * 排队期间拿不到高度=标记暂不上图;采完自然出现。宁可晚一步,也不让标记在粗/细网格间跳。
|
|
|
|
|
+ * @returns {number|null} 米
|
|
|
*/
|
|
*/
|
|
|
-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)
|
|
|
|
|
|
|
+export function deterministicGroundHeight(viewer, lon, lat) {
|
|
|
|
|
+ return queuedGroundHeight(viewer, lon, lat)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 摘掉高程刷新监听(`destroyViewer` 调用;同时丢掉该 viewer 的记忆表)。 */
|
|
|
|
|
|
|
+/** 批量预热(进入系统时把待上图坐标一次排队,不必每帧只排一个)。 */
|
|
|
|
|
+export function warmGroundHeights(viewer, points) {
|
|
|
|
|
+ const list = Array.isArray(points) ? points : []
|
|
|
|
|
+ if (!viewer || !list.length) return
|
|
|
|
|
+ const sampler = groundSamplerFor(viewer)
|
|
|
|
|
+ queueGroundSample(viewer, sampler, list
|
|
|
|
|
+ .filter((point) => point && Number.isFinite(point.lon) && Number.isFinite(point.lat))
|
|
|
|
|
+ .map((point) => ({ key: heightKey(point.lon, point.lat), lon: point.lon, lat: point.lat })))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 该 viewer 的高程采样状态(闸门读它验「采一次、不再变」)。 */
|
|
|
|
|
+export function groundSamplerState(viewer) {
|
|
|
|
|
+ const sampler = viewer ? detailedGroundHeights.get(viewer) : null
|
|
|
|
|
+ return sampler ? { size: sampler.heights.size, pending: sampler.pending.size } : { size: 0, pending: 0 }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 丢掉该 viewer 的高程采样(`destroyViewer` 调用)。 */
|
|
|
export function detachGroundHeightRefresh(viewer) {
|
|
export function detachGroundHeightRefresh(viewer) {
|
|
|
- const remove = groundRefreshDetachers.get(viewer)
|
|
|
|
|
- if (typeof remove === 'function') {
|
|
|
|
|
- try {
|
|
|
|
|
- remove()
|
|
|
|
|
- } catch {
|
|
|
|
|
- // 场景已销毁:忽略
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const sampler = detailedGroundHeights.get(viewer)
|
|
|
|
|
+ if (sampler) {
|
|
|
|
|
+ sampler.heights.clear()
|
|
|
|
|
+ sampler.pending.clear()
|
|
|
}
|
|
}
|
|
|
- groundRefreshDetachers.delete(viewer)
|
|
|
|
|
- groundAnchors.delete(viewer)
|
|
|
|
|
|
|
+ detailedGroundHeights.delete(viewer)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 让位/锚定口径的地面高程:无样本按椭球面 0 米(票 41/47 的既定判据,勿收紧)。 */
|
|
|
|
|
|
|
+/** 让位/锚定口径的地面高程:没采到时回 null(调用方兜成椭球面 0 米)。 */
|
|
|
export function groundHeightAt(viewer, lon, lat) {
|
|
export function groundHeightAt(viewer, lon, lat) {
|
|
|
- const globe = viewer && viewer.scene && viewer.scene.globe
|
|
|
|
|
- if (!globe || !Number.isFinite(lon) || !Number.isFinite(lat)) return null
|
|
|
|
|
- return sampleGroundHeightAt(globe, Cartographic.fromDegrees(lon, lat), heightKey(lon, lat))
|
|
|
|
|
|
|
+ return deterministicGroundHeight(viewer, lon, lat)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * **标记层**(DOM 覆盖层)的贴地高度:没有可信地面时回 `null`=调用方不上图。
|
|
|
|
|
|
|
+ * **标记层**(DOM 覆盖层)的贴地高度:还没采到时回 `null`=调用方不上图。
|
|
|
*
|
|
*
|
|
|
- * 口径在 `domain/groundAnchor.js`:有样本 > 最后有效高度 > (加载中:不上图)>
|
|
|
|
|
- * (加载已停:无地形覆盖,椭球面即真实地面)。让位/锚定那一路不走这里。
|
|
|
|
|
|
|
+ * 不用 `globe.getHeight`、也不用「最后有效高度」过渡:两者都依赖会随 LOD 变的网格,
|
|
|
|
|
+ * 标记依旧会在粗/细网格切换时跳。这里是**采一次、永不重采**的确定性值。
|
|
|
* @returns {number|null} 米
|
|
* @returns {number|null} 米
|
|
|
*/
|
|
*/
|
|
|
export function markerGroundHeight(viewer, lon, lat) {
|
|
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 deterministicGroundHeight(viewer, lon, lat)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 标记层的高度解算器(`projectMarkerEntity` 的参数):还没采到→不上图。 */
|
|
|
|
|
+function markerGroundHeightAt(viewer, lon, lat) {
|
|
|
|
|
+ return deterministicGroundHeight(viewer, lon, lat)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 让位/锚定口径的高度解算器(`projectMarkerEntity` 的参数)。 */
|
|
|
|
|
-function anchorGroundHeightAt(viewer, globe, carto) {
|
|
|
|
|
- const sampled = sampleGroundHeightAt(globe, carto, heightKey(CesiumMath.toDegrees(carto.longitude), CesiumMath.toDegrees(carto.latitude)))
|
|
|
|
|
|
|
+/** 让位/锚定口径的高度解算器:没采到回椭球面 0 米(判据不能比可见性更严)。 */
|
|
|
|
|
+function anchorGroundHeightAt(viewer, lon, lat) {
|
|
|
|
|
+ const sampled = deterministicGroundHeight(viewer, lon, lat)
|
|
|
return Number.isFinite(sampled) ? sampled : 0
|
|
return Number.isFinite(sampled) ? sampled : 0
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -2469,7 +2501,9 @@ function projectMarkerEntity(viewer, entity, groundOf) {
|
|
|
const carto = ellipsoid.cartesianToCartographic(original)
|
|
const carto = ellipsoid.cartesianToCartographic(original)
|
|
|
if (!carto) return null
|
|
if (!carto) return null
|
|
|
|
|
|
|
|
- const height = groundOf(viewer, viewer.scene.globe, carto)
|
|
|
|
|
|
|
+ const lon = CesiumMath.toDegrees(carto.longitude)
|
|
|
|
|
+ const lat = CesiumMath.toDegrees(carto.latitude)
|
|
|
|
|
+ const height = groundOf(viewer, lon, lat)
|
|
|
if (height === null) return null
|
|
if (height === null) return null
|
|
|
|
|
|
|
|
const world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height)
|
|
const world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height)
|