Преглед изворни кода

perf(cockpit): 收口面板缓存与地图加载性能

ChenJZ пре 13 часа
родитељ
комит
ead2ef5c31

+ 169 - 106
src/modules/cockpit/CockpitShell.vue

@@ -84,7 +84,7 @@
                 <!-- 票 102:顶栏只留在用的按钮(归位 / 返回全球)与时钟。
                      维护者 2026-11-02:配置三态、只读标记、模式读数三个诊断读数下屏 ——
                      它们既占走查视线,又把右侧状态区撑宽,挤得中列菜单左右晃动。 -->
-                <span class="cockpit-shell__clock">{{ clockText }}</span>
+                <CockpitClock />
             </div>
         </header>
 
@@ -527,6 +527,8 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import { MODES, columnsForMode, labelForPanel, modeLabel, panelGrow, panelSideFor, panelSubtitle } from './domain/panelCatalog.js'
 import { panelView, hasPanelView } from './domain/panelViews.js'
+// 票 98:面板视图按输入快照记忆化(模板里每次重渲染都会调 panelViewOf)。
+import { createViewMemo } from './domain/panelViewCache.js'
 import { CARD_ACTION } from './domain/panelBlocks.js'
 // 票 67:全局筛选(当前范围/行业)生效面的**单一措辞来源**(面板口径说明 + 管线图层那句)。
 import { PIPE_DISTRICT_NOTE } from './domain/filterCaliber.js'
@@ -552,6 +554,7 @@ import { useFilterStore } from './stores/filterStore.js'
 import { usePipeNetworkStore } from './stores/pipeNetworkStore.js'
 import { useFacilityPointStore } from './stores/facilityPointStore.js'
 import CockpitPanel from './components/CockpitPanel.vue'
+import CockpitClock from './components/CockpitClock.vue'
 import CockpitIcon from './components/CockpitIcon.vue'
 import MapCanvas from './components/MapCanvas.vue'
 import PipeEnhancement from './components/PipeEnhancement.vue'
@@ -571,7 +574,7 @@ import PointDetailCard from './components/PointDetailCard.vue'
 import FacilityDetailCard from './components/FacilityDetailCard.vue'
 import { DETAIL_LEVEL } from './domain/detailFlow.js'
 import { homeLabel } from './domain/viewPresets.js'
-import { ensureCockpitMocks } from './mocks/index.js'
+// 2026-10-30 加载优化:契约 mock 样例(约 210KB)只在 mock 模式下动态载入,
 // real 模式的首屏不再为一个用不到的样例包付出解析成本。
 // 票 14:启动计划(规格 09 的主接缝)——real 与 mock 同构启动,差别只剩 registerMocks。
 import { STARTUP_ACTION, planCockpitStartup } from './domain/startupPlan.js'
@@ -734,36 +737,58 @@ function archiveManholeCount() {
     return row && Number.isFinite(Number(row.count)) ? Number(row.count) : null
 }
 
+/** 面板视图缓存:输入快照不变就返回同一个视图对象(子组件 props 稳定 ⇒ 不重渲染)。 */
+const panelViewMemo = createViewMemo()
+
 function panelViewOf(panelId) {
     if (!hasPanelView(panelId)) return null
     const section = sectionOf(panelId) || pendingSection()
     /** 台账要额外喂一份徽标(票 18 的 N+1);票 14:监测分布的饼图要喂全局未筛分布与当前选中。 */
-    let extra
-    if (panelId === 'monitoringPointLedger') extra = monitoringBadges.value
-    else if (panelId === 'monitoringDistribution') {
-        extra = { statusDistribution: mapStore.statusDistribution, selectedStatuses: pointStatus.value }
-    }
-    // 票 64:运行信息面板要当前**片区**键,用来给指标列表算页码归属(换片区 ⇒ 页码回第 1 页,
-    // 周期刷新 ⇒ 页码保持)。只传标量、不传块对象,与票 43 第四轮「别拿对象当键」同一条纪律。
-    // 票 64:运行信息面板要当前**片区**键,用来给指标列表算页码归属(换片区 ⇒ 页码回第 1 页)。
-    // 维护者 2026-10-29:运行信息还要一个「窨井数量」读数,而本端点没有窨井指标
-    // (MANHOLE 块实测 state=EMPTY、0 条)—— 从**基础设施档案目录**搬(与「基础档案」同一份目录,
-    // 与"设施数量与基础档案同源"那条口径一致)。取不到就传 null,卡片画占位符,不造 0。
-    if (panelId === 'operation') extra = { scope: scope.value, manholeCount: archiveManholeCount() }
-    // 票 62 追加需求:运行概况的**设备台账**数据在 `monitoringOverview` 分区(`devices[]`,
-    // 与那边 `counts.equipmentCount` 同源),而本面板自己的分区是 `operationOverview`
-    // (`/monitoring/situation`:趋势/指标状态/异常口径)。跨分区取数只在这一处搬运 ——
-    // 与「一块面板的数据来自哪几处」同一立场,注册表签名保持 `(panelId, data, section, extra)`。
-    else if (panelId === 'operationOverview') {
-        const overview = monitoringSections.value.monitoringOverview
-        extra = { devices: (overview && overview.data && overview.data.devices) || [] }
-    }
-    // 2026-10-22(票 02):overview 新四块都是**单源面板**(各自一个端点、一份领域对象),
-    // 不再有「态势总览横跨三块」那种合成喂养 —— 一律直读自己的分区数据。
-    // 票 11:感知设备的类型卡可点(按行业筛地图),所以要把**当前生效的行业键**喂给它画选中态。
-    // 只传标量:卡片行自己不知道 store 是什么(渲染层也仍不认识筛选语义)。
-    else if (panelId === 'perceptionDevices') extra = { industry: filterStore.industry }
-    return panelView(panelId, section.data, section, extra)
+    // 票 98:这里是**每次重渲染都会被调用**的方法(模板里 `:view="panelViewOf(panelId)"`)。
+    // 外壳还有 500ms 归位轮询、WS 推送、store 更新等重渲染源 —— 若不记忆化,
+    // 基础设施/运行监测这两个大视图(块最多、行最大)会被反复整体重建。
+    // 所以:先用**稳定输入快照**(分区对象/数据对象/几个标量)查缓存,未变就返回同一个视图对象。
+    // 快照纪律:只放稳定引用或标量;临时对象(如 `{ scope, count }`)必须在 `build` 里现建。
+    const overview = panelId === 'operationOverview' ? monitoringSections.value.monitoringOverview : null
+    // 快照用**字段值**而不是分区对象身份:`sectionOf` 认不出的面板会走
+    // `pendingSection()`,它每次渲染都返回新字面量 —— 拿身份做键会每帧 miss,缓存等于没有。
+    // 空 `missing` 也折成 null(同一个理由:新字面量里是空数组,引用每帧都不同)。
+    const inputs = [
+        section.state,
+        section.reason,
+        section.mode,
+        section.data,
+        section.missing && section.missing.length ? section.missing : null,
+        section.freshness,
+        section.retained,
+        section.loaded,
+        // 只有对应面板消费的那一项才进快照(其余位置填 null,保持槽位对齐)。
+        panelId === 'monitoringPointLedger' ? monitoringBadges.value : null,
+        panelId === 'monitoringDistribution' ? mapStore.statusDistribution : null,
+        panelId === 'monitoringDistribution' ? pointStatus.value : null,
+        panelId === 'operation' ? scope.value : null,
+        panelId === 'operation' ? archiveManholeCount() : null,
+        panelId === 'operationOverview' ? (overview && overview.data ? overview.data.devices : null) : null,
+        panelId === 'perceptionDevices' ? filterStore.industry : null,
+    ]
+    return panelViewMemo.read(panelId, inputs, () => {
+        let extra
+        if (panelId === 'monitoringPointLedger') extra = monitoringBadges.value
+        else if (panelId === 'monitoringDistribution') {
+            extra = { statusDistribution: mapStore.statusDistribution, selectedStatuses: pointStatus.value }
+        }
+        // 票 64:运行信息要当前**片区**键(换片区 ⇒ 页码回第 1 页,周期刷新 ⇒ 页码保持)。
+        // 维护者 2026-10-29:本端点没有窨井指标(MANHOLE 块实测 state=EMPTY、0 条),
+        // 窨井数量从**基础设施档案目录**搬(与「基础档案」同源);取不到传 null,不造 0。
+        else if (panelId === 'operation') extra = { scope: scope.value, manholeCount: archiveManholeCount() }
+        // 票 62:运行概况的设备台账在 `monitoringOverview` 分区(跨分区取数只在这一处搬运)。
+        else if (panelId === 'operationOverview') {
+            extra = { devices: (overview && overview.data && overview.data.devices) || [] }
+        }
+        // 票 11:感知设备的类型卡可点,要当前生效行业键画选中态(只传标量)。
+        else if (panelId === 'perceptionDevices') extra = { industry: filterStore.industry }
+        return panelView(panelId, section.data, section, extra)
+    })
 }
 
 /**
@@ -1587,29 +1612,78 @@ function requestRefresh(options = {}) {
 }
 
 /**
- * 当前一级模式的面板取数(票 14 的面板半边)。
- * 面板取数失败不抛(各分区自己收成 FAILED/UNAVAILABLE 并如实标注),
- * 所以一块面板失败不会让本轮刷新整体失败(票 61 验收第 1 条)。
+ * 已取过面板数据的模式集合(2026-10-30 加载优化)。
+ *
+ * 旧行为:启动计划把**四个模式**的面板全部预加载(19 个请求,基础设施三块各约 0.8s),
+ * 首屏实际只需要当前模式,其余请求既占带宽又推迟首屏。
+ * 现在:启动只取当前模式;其它模式**首次进入时**再取。筛选变化会失效这里的缓存,
+ * 下次进入按最新条件重取;刷新按钮/轮询仍然只刷当前模式(`refreshModePanels`)。
  */
-async function refreshModePanels() {
-    const current = mode.value
-    if (current === 'monitoring') {
-        await monitoringStore.loadPanels({
+const loadedPanelModes = new Set()
+
+/** 离开 /cockpit 后不再启动延迟任务(重图层空闲加载可能晚于卸载)。 */
+let shellDisposed = false
+
+/** 首屏帧之后/浏览器空闲时再跑重任务;不支持 requestIdleCallback 时退化为一次短延时。 */
+function scheduleIdle(task) {
+    if (typeof window === 'undefined') return
+    const run = () => {
+        if (!shellDisposed) task()
+    }
+    if (typeof window.requestIdleCallback === 'function') {
+        window.requestIdleCallback(run, { timeout: 1200 })
+    } else {
+        window.setTimeout(run, 200)
+    }
+}
+
+/** 某个模式的面板取数(当前筛选条件;不判断缓存)。 */
+function loadPanelsForMode(target) {
+    if (target === 'monitoring') {
+        return monitoringStore.loadPanels({
             industry: industry.value,
             deviceType: deviceType.value,
             pointStatus: pointStatus.value,
             scope: scope.value,
         })
-    } else if (current === 'infrastructure') {
-        await infrastructureStore.loadPanels({ industry: industry.value, scope: scope.value })
-    } else if (current === 'warningResponse') {
+    }
+    if (target === 'infrastructure') {
+        return infrastructureStore.loadPanels({ industry: industry.value, scope: scope.value })
+    }
+    if (target === 'warningResponse') {
         // 票 67:片区也要跟着下(预警分析是事件族里唯一收片区入参的端点)。
-        await eventStore.loadPanels({ mode: eventStore.mode, industry: industry.value, scope: scope.value })
-    } else {
-        // 票 89:四模块端点已由后端补齐片区/行业入参 —— 周期刷新也按当前筛选取数,
-        // 否则刷新一次就把筛选悄悄丢回全域。
-        await overviewStore.loadPanels({ industry: industry.value, scope: scope.value })
+        return eventStore.loadPanels({
+            mode: eventStore.mode,
+            range: eventStore.range,
+            industry: industry.value,
+            scope: scope.value,
+        })
     }
+    // 票 89:四模块端点已由后端补齐片区/行业入参 —— 刷新也按当前筛选取数。
+    return overviewStore.loadPanels({ industry: industry.value, scope: scope.value })
+}
+
+/** 首次进入某个模式时取一次面板(已取过就跳过;筛选变化会清缓存)。 */
+function ensureModePanelsLoaded(target = mode.value) {
+    if (!lifecycleReady.value || loadedPanelModes.has(target)) return
+    loadedPanelModes.add(target)
+    loadPanelsForMode(target)
+}
+
+/** 筛选变化 → 失效缓存;每次进入/刷新都按最新条件重新取。 */
+function invalidateModePanels(target = '') {
+    if (target) loadedPanelModes.delete(target)
+    else loadedPanelModes.clear()
+}
+
+/**
+ * 当前一级模式的面板取数(票 14 的面板半边)。
+ * 面板取数失败不抛(各分区自己收成 FAILED/UNAVAILABLE 并如实标注),
+ * 所以一块面板失败不会让本轮刷新整体失败(票 61 验收第 1 条)。
+ */
+async function refreshModePanels() {
+    loadedPanelModes.add(mode.value)
+    await loadPanelsForMode(mode.value)
 }
 
 // ---- 配置状态(票 04):票 102 起顶栏不再显示配置读数,这里只留判定结果,
@@ -1865,17 +1939,12 @@ function onAnyActivity() {
     markActivity()
 }
 
-// ---- 顶栏时钟 ----
-const clockText = ref('')
-let clockTimer = null
+// ---- 顶栏时钟:已抽成 <CockpitClock /> 子组件(票 98)----
+// 秒针若留在这里,1Hz 会重渲染整个外壳 —— 而外壳模板里对**每个可见面板**都要调
+// panelViewOf(),基础设施/运行监测这类大视图因此每秒被整体重建一次(维护者报的卡顿)。
+// 时钟关进自己的组件后,1Hz 更新只重渲染那一个 span。
 let homeTimer = null
 
-function tick() {
-    const now = new Date()
-    const pad = (n) => String(n).padStart(2, '0')
-    clockText.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
-}
-
 /**
  * 票 41:锚定跟随的帧驱动与恢复。
  *
@@ -1889,8 +1958,7 @@ let stopCameraState = null
 let anchorResizeHandler = null
 
 onMounted(() => {
-    tick()
-    clockTimer = setInterval(tick, 1000)
+    // 顶栏时钟由 <CockpitClock /> 自带计时器(票 98)——外壳不再有 1Hz 重渲染源。
     // 归位按钮的高亮状态跟着相机走(用户手动平移后就不再算"在家")。
     homeTimer = setInterval(refreshHomeState, 500)
 
@@ -1905,7 +1973,7 @@ onMounted(() => {
     }
 
     // 票 04:配置只在这个入口载入一次(服务内部有缓存),地图服务按需 await 同一份。
-    loadCockpitConfig().then((result) => {
+    loadCockpitConfig().then(async (result) => {
         configState.value = result
         // 配置到手才知道引导开关与预设坐标,重新取一次。
         intro.value = getIntroConfig()
@@ -1923,11 +1991,30 @@ onMounted(() => {
         const plan = planCockpitStartup(
             withUrlDataSourceOverride(result.config, null, !!(import.meta.env && import.meta.env.DEV)),
         )
+        // 配置一次下发到四个面板 store:面板改为**按需取数**后,未进入的模式也要能随时被
+        // `ensureModePanelsLoaded` 直接取,不再依赖各自的启动分支先 configure。
+        overviewStore.configure({ config: result.config })
+        infrastructureStore.configure({ config: result.config })
+        monitoringStore.configure({ config: result.config })
+        eventStore.configure({ config: result.config })
         for (const action of plan.actions) {
             if (action === STARTUP_ACTION.REGISTER_MOCKS) {
+                // 只有 mock 模式才需要样例包;real 的首屏不加载它。
+                const { ensureCockpitMocks } = await import('./mocks/index.js')
                 ensureCockpitMocks()
                 continue
             }
+            // 2026-10-30 加载优化:启动计划仍产出四个模式的面板动作(契约不变),但执行时
+            // **只取当前模式** —— 其余模式首次进入时由 `ensureModePanelsLoaded` 取。
+            // 旧行为把 19 个请求全打在启动上(基础设施三块各约 0.8s),首屏并不需要它们。
+            if (
+                action === STARTUP_ACTION.LOAD_INFRASTRUCTURE_PANELS ||
+                action === STARTUP_ACTION.LOAD_MONITORING_PANELS ||
+                action === STARTUP_ACTION.LOAD_EVENT_PANELS ||
+                action === STARTUP_ACTION.LOAD_OVERVIEW_PANELS
+            ) {
+                continue
+            }
             // 票 18:**先把筛选交给地图**,再让共享分区去取第 1 页(顺序纪律不变)。
             // 若筛选晚于它,第 1 页就带着空筛选发出去 —— 那正是"筛了但第一屏没筛上"的经典来源。
             // (`mapStore.setup()` 是合并语义,所以后到的调用不会抹掉这里的条件。)
@@ -1948,42 +2035,18 @@ onMounted(() => {
                 // 会把 LOAD_MAP_SUMMARY 刚合并进 mapStore 的 config 键覆盖掉(合并语义下
                 // `undefined` 也是一次写入),real 下首批摘要请求就只能靠配置缓存兜底了。
                 detailStore.loadSharedSections({ config: result.config })
-                // 票 24:设施点位与管线是**跨模式共享**的图层(不随一级模式切换重取),
-                // 所以跟共享分区一起启动,而不是塞进某个模式的 loadPanels。
-                // 票 67 更正:片区也要交给管线图层(本地按编码前缀裁剪,不额外发请求)。
-                pipeNetworkStore.load({ industry: industry.value, scope: scope.value, config: result.config })
                 // 票 24/68:设施点位与管线是**跨模式共享**的图层(不随一级模式切换重取)。
-                // 票 68:行业/片区/设施类型筛选都在 store 的 filtered 里本地生效。
-                facilityPointStore.load({ config: result.config })
-                continue
-            }
-            // 筛选用全局条件的当前值(CONTEXT.md:行业筛选作用于整个驾驶舱)。
-            if (action === STARTUP_ACTION.LOAD_INFRASTRUCTURE_PANELS) {
-                infrastructureStore.configure({ config: result.config })
-                infrastructureStore.loadPanels({ industry: industry.value, scope: scope.value })
-                continue
-            }
-            if (action === STARTUP_ACTION.LOAD_MONITORING_PANELS) {
-                monitoringStore.configure({ config: result.config })
-                monitoringStore.loadPanels({
-                    industry: industry.value,
-                    deviceType: deviceType.value,
-                    pointStatus: pointStatus.value,
-                    scope: scope.value,
+                // 2026-10-30 加载优化:几何一次约 6MB、设施点还要逐条补详情,属于首屏之后
+                // 才看得见的重图层 —— 放到浏览器空闲时再取,先让外壳/面板/引导页完成首帧。
+                // 地图侧仍按 store 数据到达后同步图层,不会漏画。
+                scheduleIdle(() => {
+                    // 票 67 更正:片区也要交给管线图层(本地按编码前缀裁剪,不额外发请求)。
+                    pipeNetworkStore.load({ industry: industry.value, scope: scope.value, config: result.config })
+                    // 票 68:行业/片区/设施类型筛选都在 store 的 `filtered` 里本地生效。
+                    facilityPointStore.load({ config: result.config })
                 })
                 continue
             }
-            if (action === STARTUP_ACTION.LOAD_EVENT_PANELS) {
-                eventStore.configure({ config: result.config })
-                eventStore.loadPanels({ mode: 'WARNING', range: '7d', industry: industry.value })
-                continue
-            }
-            if (action === STARTUP_ACTION.LOAD_OVERVIEW_PANELS) {
-                overviewStore.configure({ config: result.config })
-                // 票 89:启动取数同样带上全局筛选(与基础设施/运行监测量块同一条纪律)。
-                overviewStore.loadPanels({ industry: industry.value, scope: scope.value })
-                continue
-            }
             // 票 15:状态推送。外壳只说"用配置启动"——地址、凭据与 mock/real 的连接方式
             // 都由 runtimeStore 决定(页面因此不认识凭据,也不认识假连接)。
             // 没配地址时服务进 `disabled`,界面如实说"未配置",不假装连上。
@@ -2011,6 +2074,8 @@ onMounted(() => {
             }
         }
         lifecycleReady.value = true
+        // 首屏只取当前模式;其它模式在第一次切过去时再取(筛选变化会清缓存)。
+        ensureModePanelsLoaded(mode.value)
     })
 
     // 引导期间显示自转;进入后停(引擎就绪前调用会被忽略,等引擎 ready 再兜一次)。
@@ -2076,6 +2141,8 @@ onMounted(() => {
             if (!facilityDetailOpen.value) entityDetailStore.close()
             applyModeSelectionTransition(prev, next)
             syncDeviceLayer({ force: true })
+            // 面板按需取:第一次进入该模式时补一次;已取过则由筛选变化负责失效。
+            ensureModePanelsLoaded(next)
         }, { deep: false }),
         // 票 46:引导态 ⇄ 进入系统的门控(规格 18:entered 决定一切业务对象显隐)。
         watch(introVisible, (next) => {
@@ -2104,12 +2171,9 @@ onMounted(() => {
  */
 watch(industry, (next) => {
     if (!lifecycleReady.value) return
-    infrastructureStore.loadPanels({ industry: next })
-    // 票 18:运行监测三块也跟随;地图换新快照重载(Q9:丢弃旧范围的已加载对象)。
-    monitoringStore.loadPanels({ industry: next })
-    // 票 13:当前预警的事件列表跟随全局行业(`/events/current` 收 industry 入参)。
-    eventStore.loadPanels({ mode: eventStore.mode, industry: next })
-    overviewStore.loadPanels({ industry: next })
+    // 2026-10-30 加载优化:只立即重取**当前模式**;其它模式失效缓存,切过去时按新条件取。
+    invalidateModePanels()
+    refreshModePanels()
     // 票 24:行业筛选同样作用到管线与构件图层。几何是**全量取回、本地裁剪**,
     // 所以切行业只改本地筛选条件、不重新发请求(切换必须即时,不等一个往返)。
     pipeNetworkStore.setIndustry(next)
@@ -2131,12 +2195,9 @@ watch(industry, (next) => {
  */
 watch(scope, (next) => {
     if (!lifecycleReady.value) return
-    infrastructureStore.loadPanels({ scope: next })
-    // 票 89:概览四块已接手片区入参,跟随重取(与行业筛选同一条纪律)。
-    overviewStore.loadPanels({ scope: next })
-    monitoringStore.loadPanels({ scope: next })
-    // 换片区 = 换一批数据 ⇒ 页码回第 1 页由 eventStore 的条件键(含 `scope`)负责。
-    eventStore.loadPanels({ mode: eventStore.mode, scope: next })
+    // 2026-10-30 加载优化:同行业筛选 —— 只刷当前模式,其它模式切过去时再取。
+    invalidateModePanels()
+    refreshModePanels()
     // 票 67 更正(维护者 2026-10-23):管线与构件**也能按片区筛** —— 片区从设施编码前缀推
     // (片区+行业+时间编码)。几何是全量取回、本地裁剪,所以这里只改条件、不重新发请求。
     pipeNetworkStore.setScope(next)
@@ -2150,7 +2211,9 @@ watch(scope, (next) => {
  */
 watch(pointStatus, (next) => {
     if (!lifecycleReady.value) return
-    monitoringStore.loadPanels({ pointStatus: next })
+    // 只有运行监测吃点位状态;当前不在该模式时只失效缓存,切过去再取。
+    invalidateModePanels('monitoring')
+    if (mode.value === 'monitoring') refreshModePanels()
     refreshMapForFilters({ pointStatus: next })
 })
 
@@ -2163,7 +2226,8 @@ watch(pointStatus, (next) => {
  */
 watch(deviceType, (next) => {
     if (!lifecycleReady.value) return
-    monitoringStore.loadPanels({ deviceType: next })
+    invalidateModePanels('monitoring')
+    if (mode.value === 'monitoring') refreshModePanels()
     refreshMapForFilters({ deviceType: next })
 })
 
@@ -2175,8 +2239,9 @@ function refreshMapForFilters(next) {
 }
 
 onBeforeUnmount(() => {
+    shellDisposed = true
     entityDetailStore.close()
-    if (clockTimer) clearInterval(clockTimer)
+
     if (homeTimer) clearInterval(homeTimer)
     // 票 41:锚定跟随的帧订阅与尺寸监听必须拆掉(与票 15/37 的"漏取消定时器"是同一个坑)。
     if (stopMapFrame) {
@@ -2596,10 +2661,8 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
 
 /* 票 102(维护者:时间字体长短不平均,秒针一跳按钮就跟着左右挪):
    时钟改为**固定宽度的数字槽位**,槽位内右对齐 + 等宽数字(tabular-nums + tnum 兜底)。
-   宽度按 2384×1160 截图实测(该图 CSS 视口即 2384×1160、--u=1.4326px;
-   同图 483px 的导航字形跨度 ÷1.4326 = 337u,与「4×90u+3×4u−2×17u内边距」吻合,
-   可反证 --u 取值无误):一次读数 `21:22:22` 的字形跨度 145px = 101u,
-   取 128u(+27% 余量)覆盖「把 1 换成 8」的最宽读数与字体回退
+   宽度按 2384×1160 截图实测:`--u` 在该视口 ≈1.433px(0.1235vh),一次读数 `21:22:22`
+   的字形跨度 145px ≈ 101u,取 128u(+27% 余量)覆盖「把 1 换成 8」的最宽读数与字体回退
    (Rajdhani → Bahnschrift → Segoe,等宽数字不被支持时)—— 槽位只要不吃到内容宽度,
    宽度就恒由这条声明决定:秒针跳动只改槽位内的字形,不再改动任何布局宽度。
    flex: 0 0 auto —— 槽位不参与挤压,右列内容再多也不会压缩它。 */

+ 35 - 0
src/modules/cockpit/components/CockpitClock.vue

@@ -0,0 +1,35 @@
+<script setup>
+// 顶栏时钟(票 98)。
+//
+// 为什么必须**单独成组件**:秒针每秒变一次,若把 `clockText` 留在 CockpitShell,
+// 每秒都会触发整个外壳重渲染 —— 模板里每个可见面板的 `panelViewOf()` 都要重算,
+// 基础设施/运行监测这类大视图每秒全量重建一次,页面直接卡(维护者 2026-10-29 报障)。
+// 把时钟关进自己的组件后,1Hz 更新只重渲染这一个 span。
+import { onBeforeUnmount, onMounted, ref } from 'vue'
+
+const clockText = ref('')
+let timer = null
+
+function pad(value) {
+    return String(value).padStart(2, '0')
+}
+
+function tick() {
+    const now = new Date()
+    clockText.value = pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds())
+}
+
+onMounted(() => {
+    tick()
+    timer = setInterval(tick, 1000)
+})
+
+onBeforeUnmount(() => {
+    if (timer) clearInterval(timer)
+    timer = null
+})
+</script>
+
+<template>
+    <span class="cockpit-shell__clock">{{ clockText }}</span>
+</template>

+ 26 - 1
src/modules/cockpit/domain/adaptiveResolution.js

@@ -16,6 +16,28 @@
  * 纯函数:输入窗口统计与当前档位,输出下一步档位与计时器,脱浏览器可断言。
  */
 
+/**
+ * 「连续渲染」的帧间隔上限:100ms(=10fps)。
+ *
+ * 超过它的间隔**不是"帧很慢",而是"根本没在连续渲染"** —— 本仓开了 requestRenderMode,
+ * 静止时只在需要时渲一帧,两帧之间可能是几百毫秒甚至几分钟。
+ *
+ * 真机走查踩到的坑(2026-10-29):这些按需渲染空档被当成帧耗时喂进自适应分辨率,
+ * p95 变成 399ms ⇒ 控制器认定"机器严重卡顿" ⇒ 一路降到下限 0.55 且再也回不来(画质白降)。
+ */
+export const MAX_CONTINUOUS_GAP_MS = 100
+
+/**
+ * 这个间隔是否属于"连续渲染"(可作为帧预算信号)。
+ * @param {number} intervalMs
+ * @param {number} [maxGapMs]
+ * @returns {boolean}
+ */
+export function isContinuousGap(intervalMs, maxGapMs = MAX_CONTINUOUS_GAP_MS) {
+    const n = Number(intervalMs)
+    return Number.isFinite(n) && n > 0 && n <= maxGapMs
+}
+
 /** 帧间隔窗口的分位数(p95)—— 与 DEV 面板的 movingFrameIntervals 同口径。 */
 export function percentile(sorted, p) {
     if (!Array.isArray(sorted) || !sorted.length) return null
@@ -92,5 +114,8 @@ export function stepAdaptiveResolution(state = {}) {
  */
 export function frameIntervalP95(intervalsMs, minSamples = 30) {
     if (!Array.isArray(intervalsMs) || intervalsMs.length < minSamples) return null
-    return percentile(intervalsMs.filter((n) => Number.isFinite(n) && n > 0), 0.95)
+    // 兜底再滤一次"按需渲染空档":即使上游漏进来,也不让它主导 p95(见 MAX_CONTINUOUS_GAP_MS)。
+    const usable = intervalsMs.filter((n) => isContinuousGap(n))
+    if (usable.length < minSamples) return null
+    return percentile(usable, 0.95)
 }

+ 21 - 0
src/modules/cockpit/domain/detailBands.js

@@ -0,0 +1,21 @@
+/** Select already nearest-first candidates under the global budget.
+ * The previous near-band quota rejected close buildings but admitted farther ones.
+ * Crossing 300m could therefore unload a cached near region and leave permanent coarse blocks.
+ * LOD refinement belongs to Cesium's tile tree, not to a region eviction quota.
+ */
+export function selectByDistanceBands(candidates, options = {}) {
+    const list = Array.isArray(candidates) ? candidates.filter(Boolean) : []
+    const number = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback
+    const maxContents = number(options.maxContents, 1200)
+    const maxBytes = number(options.maxDecodedBytes, 256 * 1024 * 1024)
+    const maxRegions = number(options.maxRegions, Infinity)
+    const selected = []
+    let contents = 0, bytes = 0
+    for (const region of list) {
+        if (selected.length >= maxRegions) break
+        const count = number(region.contentCount, 0), size = number(region.decodedGeometryBytes, 0)
+        if (contents + count > maxContents || bytes + size > maxBytes) continue
+        selected.push(region.id); contents += count; bytes += size
+    }
+    return selected
+}

+ 66 - 0
src/modules/cockpit/domain/heightCache.js

@@ -0,0 +1,66 @@
+/**
+ * 地形高程的**短时缓存**(票 98 第二轮)。
+ *
+ * ## 为什么要缓存
+ *
+ * DOM 标记层每帧都要把 `(lon, lat)` 投到屏幕,而投影前要先取地面高程
+ * (`globe.getHeight`,一次四叉树下降)。标记位置是**静态**的,但相机一动,
+ * 每帧都会对**每个标记**重查一遍 —— 几百个标记就是几百次四叉树下降,
+ * 这是近景拖动时主线程的主要开销之一。
+ *
+ * 实体投影那条路径早就按实体缓存了 3 秒(见 `projectMarkerEntity` 的注释);
+ * 这里把同一口径用到"按坐标"的调用上,两条路径的语义就一致了。
+ *
+ * ## 为什么用 TTL 而不是永久缓存
+ *
+ * 地形瓦片是**逐步补齐**的:早期查不到高程会落到椭球面,补到之后才准。
+ * 所以缓存必须过期后重采样,否则标记会一直贴在错的(椭球面)高度上。
+ *
+ * ## 一条硬纪律
+ *
+ * **只缓存有效值**。地形还没铺到时 `getHeight` 回 `undefined`/`NaN`,
+ * 若把这种"没取到"也缓存 3 秒,标记就会长时间停在椭球面 —— 比不缓存更糟。
+ */
+
+/** 坐标键的默认精度:经纬度 5 位小数约 1m,足够合并同一栋楼上的重复查询。 */
+export const HEIGHT_KEY_PRECISION = 5
+
+/**
+ * 经纬度 → 缓存键。
+ * @param {number} lon
+ * @param {number} lat
+ * @param {number} [precision]
+ * @returns {string}
+ */
+export function heightKey(lon, lat, precision = HEIGHT_KEY_PRECISION) {
+    return Number(lon).toFixed(precision) + ',' + Number(lat).toFixed(precision)
+}
+
+/**
+ * 建一个高程缓存。
+ *
+ * `read(key, nowMs, produce)`:TTL 内直接给缓存值;否则调 `produce()` 重采样,
+ * 且**仅在结果有限时**写回。超过容量就整表清空(宁可重采一轮,也不做 LRU 的额外记账)。
+ *
+ * @param {object} [options]
+ * @param {number} [options.ttlMs] 过期时间(默认 3000,与实体高程缓存同口径)
+ * @param {number} [options.maxEntries] 容量上限(默认 5000)
+ */
+export function createHeightCache({ ttlMs = 3000, maxEntries = 5000 } = {}) {
+    const slots = new Map()
+    return {
+        read(key, nowMs, produce) {
+            const slot = slots.get(key)
+            if (slot && nowMs - slot.at < ttlMs) return slot.value
+            const value = produce()
+            // 只缓存有效值(见文件头):取不到就让它下一帧再试。
+            if (Number.isFinite(value)) {
+                if (slots.size >= maxEntries) slots.clear()
+                slots.set(key, { value, at: nowMs })
+            }
+            return value
+        },
+        size: () => slots.size,
+        clear() { slots.clear() },
+    }
+}

+ 77 - 0
src/modules/cockpit/domain/panelViewCache.js

@@ -0,0 +1,77 @@
+/**
+ * 面板视图的**输入快照记忆化**(票 98)。
+ *
+ * ## 为什么需要
+ *
+ * 外壳模板里写的是 `:view="panelViewOf(panelId)"` —— 方法调用,**每次重渲染都会重新算**。
+ * 而外壳有一颗 1Hz 的时钟(顶栏秒针)、还有 500ms 归位轮询、WS 推送、store 更新……
+ * 任何一次重渲染都会把**每个可见面板**的视图计划整个重建一遍(含全部行)。
+ *
+ * 基础设施与运行监测恰好是块最多、行数最大的两块(运行指标实测 6772 条),
+ * 于是「每秒一次全量重建」直接表现为这两个页面特别卡。
+ *
+ * ## 做法
+ *
+ * 视图是输入的函数:`(panelId, data, section, extra) → view`。只要**输入的引用/标量没变**,
+ * 结果就必然一样 —— 那就返回上一次那个**同一个对象**。子组件 props 引用稳定 ⇒ 不重渲染,
+ * 相关 computed 也不重算。这是一层纯缓存,不改变任何渲染语义。
+ *
+ * ## 纪律(容易踩的两条)
+ *
+ * 1. **快照里只放稳定引用或标量**。每次渲染新建的临时对象(如 `{ scope, count }`)不能进快照,
+ *    否则每帧都 miss,缓存等于没有 —— 与票 43「别拿对象当键」同一条。
+ * 2. 分区对象被 store **原地改写**时,对象身份不变但内容变了。所以快照必须同时带上
+ *    视图真正消费的那几个标量(`state` / `updatedAt` 等),不能只靠对象身份。
+ */
+
+/**
+ * 两个输入快照是否等价:逐槽 Object.is(因此 NaN 与 NaN 等价)。
+ * @param {Array} a
+ * @param {Array} b
+ * @returns {boolean}
+ */
+export function sameInputs(a, b) {
+    if (a === b) return true
+    if (!Array.isArray(a) || !Array.isArray(b)) return false
+    if (a.length !== b.length) return false
+    for (let i = 0; i < a.length; i += 1) {
+        // 用 Object.is 而不是 ===:它把 NaN 与 NaN 视为相等(=== 会判不等,
+        // 于是含 NaN 的槽每帧 miss,缓存等于没有)。0 与 -0 仍判不等,这是更严的口径。
+        if (!Object.is(a[i], b[i])) return false
+    }
+    return true
+}
+
+/**
+ * 建一个按 id 分槽的视图缓存。
+ *
+ * `read(id, inputs, build)`:输入快照与上次相同就返回**同一个对象**(不调 `build`);
+ * 否则调 `build()` 并按新快照记账。`inputs` 里含 NaN 也不会每次都 miss。
+ *
+ * @returns {{read: (id: string, inputs: Array, build: () => any) => any, clear: () => void, hits: () => number, misses: () => number}}
+ */
+export function createViewMemo() {
+    const slots = new Map()
+    let hits = 0
+    let misses = 0
+    return {
+        read(id, inputs, build) {
+            const slot = slots.get(id)
+            if (slot && sameInputs(slot.inputs, inputs)) {
+                hits += 1
+                return slot.view
+            }
+            misses += 1
+            const view = build()
+            slots.set(id, { inputs: Array.isArray(inputs) ? inputs.slice() : inputs, view })
+            return view
+        },
+        clear() {
+            slots.clear()
+            hits = 0
+            misses = 0
+        },
+        hits: () => hits,
+        misses: () => misses,
+    }
+}

+ 52 - 0
src/modules/cockpit/domain/readoutCache.js

@@ -0,0 +1,52 @@
+/**
+ * 读数对象的**稳定引用**(票 98)。
+ *
+ * ## 为什么需要
+ *
+ * 外壳有一颗 500ms 的归位轮询,每轮把 `getAreaLimits()` / `getFogState()` 的返回值写进 ref。
+ * 这两个函数**每次都返回新对象** ⇒ ref 每 500ms 被写入一次 ⇒ 整个外壳 2Hz 重渲染。
+ * 模板很大(四模式面板 + 顶栏 + 地图叠层),而内容其实早就没变 —— 这是白烧帧预算。
+ *
+ * 做法:按**值的序列化键**缓存。键没变就返回上一次那个对象(引用稳定 ⇒ Vue 的 ref 写入
+ * 因为 Object.is 相等而不触发重渲染);真的变了才造新对象。
+ *
+ * ## 边界
+ *
+ * 只适合**纯读数**:输出完全由当前状态决定、不含时间戳/随机量。
+ * 若读数里有 `Date.now()` 之类易变字段,键每次都会变 —— 缓存不帮忙,但也不会给出错值。
+ */
+
+/**
+ * 值的比较键:标量直接转字符串,对象走 JSON(字段顺序由产出方固定,稳定)。
+ * @param {*} value
+ * @returns {string|null} 无法序列化时回 null(调用方据此退化为"总是重算")
+ */
+export function readoutKey(value) {
+    if (value === null || typeof value !== 'object') return String(value)
+    try {
+        return JSON.stringify(value)
+    } catch {
+        return null
+    }
+}
+
+/**
+ * 把读数函数包成"值不变就返回同一对象"的版本。
+ * @param {() => any} produce 产出读数(必须是纯读数,见文件头)
+ * @returns {() => any}
+ */
+export function createStableReadout(produce) {
+    let lastKey = null
+    let lastValue = null
+    let primed = false
+    return function stableReadout() {
+        const nextValue = produce()
+        const nextKey = readoutKey(nextValue)
+        // 键拿不到(循环引用等)就老老实实交新对象,不猜。
+        if (primed && nextKey !== null && nextKey === lastKey) return lastValue
+        lastKey = nextKey
+        lastValue = nextValue
+        primed = true
+        return lastValue
+    }
+}

+ 52 - 0
src/modules/cockpit/domain/refineSchedule.js

@@ -0,0 +1,52 @@
+/** Distance-dependent SSE policy.
+ * Lower SSE requests more detail; this is not a separate preload/render policy.
+ * A constant SSE=20 yields a theoretical ge=20 boundary near 701m at H=810/fovy=60.
+ * That does NOT imply the dynamic curve starts loading at 701m: solve SSE(distance)
+ * together with the individual tile distance. Region admission distance, viewport,
+ * memoryAdjustedScreenSpaceError and current geometry bounds also affect selection.
+ */
+
+/**
+ * 由距离算出该区域的细化阈值。
+ *
+ * @param {number} distanceM 到该区域内容球面的距离(米)
+ * @param {object} [options]
+ * @param {number} [options.baseThreshold] 基准阈值(远距离用,默认 56)
+ * @param {number} [options.nearThreshold] 近距离阈值(更小 = 更早细化,默认 20)
+ * @param {number} [options.nearBandM] 近档距离(默认 200m,此距离内用 nearThreshold)
+ * @param {number} [options.farBandM] 远档距离(默认 1200m,此距离外用 baseThreshold)
+ * @returns {number} 该区域这一帧应使用的 maximumScreenSpaceError
+ */
+export function refineThresholdForDistance(distanceM, options = {}) {
+    const opts = options || {}
+    const num = (v, f) => (Number.isFinite(Number(v)) ? Number(v) : f)
+    const base = Math.max(1, num(opts.baseThreshold, 56))
+    const near = Math.max(1, num(opts.nearThreshold, 20))
+    const nearBand = Math.max(1, num(opts.nearBandM, 200))
+    const farBand = Math.max(nearBand + 1, num(opts.farBandM, 1200))
+    const d = Number(distanceM)
+    // 距离未知(还没算过):用基准阈值,不要因为 NaN 就疯狂细化。
+    if (!Number.isFinite(d)) return base
+    // 近档内:直接给最紧阈值(最早细化)。
+    if (d <= nearBand) return near
+    // 远档外:基准阈值。
+    if (d >= farBand) return base
+    // 中间:线性插值(近→紧、远→宽),保证连续、无跳变。
+    const t = (d - nearBand) / (farBand - nearBand)
+    return near + (base - near) * t
+}
+
+/**
+ * 该阈值下,某层几何误差对应的细化距离(诊断/闸门用)。
+ * @param {number} geometricError
+ * @param {number} threshold
+ * @param {number} [viewportHeightPx]
+ * @returns {number} 米
+ */
+export function refineDistance(geometricError, threshold, viewportHeightPx = 810) {
+    const ge = Number(geometricError)
+    const thr = Number(threshold)
+    if (!Number.isFinite(ge) || !Number.isFinite(thr) || thr <= 0) return Infinity
+    const factor = 2 * Math.tan(Math.PI / 6)
+    return (ge * viewportHeightPx) / (thr * factor)
+}

+ 58 - 0
src/modules/cockpit/domain/renderResolutionController.js

@@ -0,0 +1,58 @@
+import { clampResolution, motionResolution } from './renderResolution.js'
+import { frameIntervalP95, isContinuousGap, stepAdaptiveResolution } from './adaptiveResolution.js'
+
+/** One owner combines motion caps and adaptive decisions. Static views always use base quality.
+ * applyScale checks the real renderer value; decisions commit even when no physical write is needed.
+ * No viewer/Cesium dependency, and no wall-clock sleeps in tests.
+ */
+export function createRenderResolutionController({ applyScale, now, options = {} }) {
+    let base, movingCap, floor, enabled, moving = false, adaptiveTarget
+    let intervals = [], lastSampleAt = null, lastEvaluationAt = -Infinity
+    let hotSinceMs = null, coldSinceMs = null, p95Ms = null, writes = 0
+    const clearSamples = () => {
+        intervals = []; lastSampleAt = null; lastEvaluationAt = -Infinity
+        hotSinceMs = coldSinceMs = p95Ms = null
+    }
+    const readOptions = values => {
+        base = clampResolution(values.resolutionScale) ?? .75
+        movingCap = motionResolution({ base, moving: values.movingResolutionScale ?? .6, isMoving: true })
+        floor = Math.min(movingCap, clampResolution(values.adaptiveFloor) ?? .55)
+        enabled = values.adaptiveResolution !== false
+        adaptiveTarget = movingCap
+    }
+    const effectiveTarget = () => moving ? Math.min(movingCap, adaptiveTarget) : base
+    const apply = () => {
+        const changed = !!applyScale(effectiveTarget())
+        if (changed) writes++
+        return changed
+    }
+    readOptions(options)
+    return {
+        configure(values = {}) { readOptions(values); clearSamples(); return apply() },
+        setMoving(value) {
+            if (moving !== (value === true)) { moving = value === true; clearSamples() }
+            return apply()
+        },
+        noteFrameInterval(value) {
+            if (!moving || !enabled || !isContinuousGap(value)) return false
+            intervals.push(Number(value)); if (intervals.length > 90) intervals.shift()
+            lastSampleAt = now()
+            return true
+        },
+        tick() {
+            if (!moving || !enabled) return false
+            const at = now()
+            if (lastSampleAt === null || at - lastSampleAt > 1500) { clearSamples(); return false }
+            if (at - lastEvaluationAt < 500) return false
+            p95Ms = frameIntervalP95(intervals)
+            if (p95Ms === null) return false
+            lastEvaluationAt = at
+            const step = stepAdaptiveResolution({ p95Ms, currentScale: Math.min(movingCap, adaptiveTarget), baseScale: movingCap, floorScale: floor, nowMs: at, hotSinceMs, coldSinceMs })
+            hotSinceMs = step.hotSinceMs; coldSinceMs = step.coldSinceMs
+            // State progresses even if motion already wrote the same target to the framebuffer.
+            adaptiveTarget = step.scale
+            return step.changed ? apply() : false
+        },
+        status() { return { scale: effectiveTarget(), adaptiveTarget, moving, baseScale: base, movingCap, floorScale: floor, enabled, samples: intervals.length, p95Ms, writes } },
+    }
+}

+ 12 - 0
src/modules/cockpit/domain/tileCoverage.js

@@ -0,0 +1,12 @@
+/** True only when every visible branch has resident content or covered descendants.
+ * A single tileLoad/tileVisible event is not proof that an entire region can replace its coarse mesh.
+ * The producer supplies Cesium's current-frustum visibility check; no camera or Cesium dependency here.
+ */
+export function hasVisibleTileCoverage(tile, visible) {
+    if (!tile) return false
+    if (!visible(tile)) return true
+    const renderable = tile.hasEmptyContent !== true && tile.hasTilesetContent !== true && tile.hasRenderableContent !== false
+    if (renderable && tile.contentReady === true) return true
+    const children = tile.children || []
+    return children.length > 0 && children.every(child => hasVisibleTileCoverage(child, visible))
+}

+ 59 - 88
src/modules/cockpit/services/cesiumEngine.js

@@ -58,7 +58,8 @@ import { ellipsisForMapLabel } from '../domain/ellipsis.js'
 import { createTilesetLoadingController } from './tilesetLoading.js'
 import { setTilesetPerformance, tilesetOptions, detailTilesetOptions, setDetailTilesetPerformance } from './tilesetOptions.js'
 import { createRegionLoadingController, contentAdmission } from './regionLoading.js'
-import { clampResolution, motionResolution, sameResolution } from '../domain/renderResolution.js'
+import { hasVisibleTileCoverage } from '../domain/tileCoverage.js'
+import { clampResolution, sameResolution } from '../domain/renderResolution.js'
 import { removeSceneTileset } from './tilesetCleanup.js'
 const regionControllers = new WeakMap()
 // 票 47:动效的**相位与映射**同样在 domain(纯函数,可跨时间采样断言);
@@ -287,7 +288,7 @@ let activePerformance = { ...PERFORMANCE_DEFAULTS }
  * 与 `activePerformance` 分开,是因为它只影响"细模覆盖到多远/占多少预算",
  * 不改变任何渲染开销档位(两者可以独立回退)。
  */
-let activeDetail = { frontier: null, budget: null, tiersConfigured: false }
+let activeDetail = { frontier: null, budget: null, tiersConfigured: false, refineSchedule: null }
 
 /** 应用细模覆盖档(`map.detail`);脏值忽略,交给 domain 的默认档兜底。 */
 export function applyDetailOptions(options = {}) {
@@ -299,6 +300,8 @@ export function applyDetailOptions(options = {}) {
         // 这条优先级对"低配机把 fine 收到 150m"至关重要:若 manifest 的 250m
         // 每次加载都覆盖配置,运维旋钮就形同虚设。
         tiersConfigured: Array.isArray(next.tiers) && next.tiers.length >= 2,
+        // 细化提前量(map.detail.refine):近档阈值/带宽/远近边界。未配置即用 domain 默认。
+        refineSchedule: next.refine && typeof next.refine === 'object' ? next.refine : null,
     }
     // 细模批次的 SSE / 中心偏置也归这里(`map.detail.fineScreenSpaceError` / `fineFoveated`)。
     try { setDetailTilesetPerformance(next) } catch { /* 忽略 */ }
@@ -306,82 +309,35 @@ export function applyDetailOptions(options = {}) {
 
 export const PERFORMANCE_DEFAULT_OPTIONS = { ...PERFORMANCE_DEFAULTS }
 
-import { stepAdaptiveResolution, frameIntervalP95 } from '../domain/adaptiveResolution.js'
-
-/** 自适应分辨率控制器(票 61 第十九轮)。惰性初始化,第一次 tick 才建。 */
-let adaptiveResolution = null
-
-/**
- * 惰性建控制器(frameIntervalP95 / stepAdaptiveResolution 见 domain)。
- */
-function ensureAdaptiveResolution() {
-    if (adaptiveResolution) return adaptiveResolution
-    adaptiveResolution = {
-        intervals: [],
-        scale: null,        // 惰性取部署基准档(首次应用时定)
-        hotSinceMs: null,
-        coldSinceMs: null,
+import { isContinuousGap } from '../domain/adaptiveResolution.js'
+import { createRenderResolutionController } from '../domain/renderResolutionController.js'
+import { createHeightCache, heightKey } from '../domain/heightCache.js'
+
+// Per-viewer state avoids inheriting samples/targets from a destroyed or different viewer.
+const resolutionControllers = new WeakMap()
+function resolutionControllerFor(viewer) {
+    if (!viewer?.scene || viewer.isDestroyed?.()) return null
+    let controller = resolutionControllers.get(viewer)
+    if (!controller) {
+        controller = createRenderResolutionController({ applyScale: scale => setRenderResolution(viewer, scale), now: () => typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(), options: activePerformance })
+        resolutionControllers.set(viewer, controller)
     }
-    return adaptiveResolution
+    return controller
 }
 
-/**
- * 记录一帧间隔(帧间隔超过 1s 视为切后台/长暂停,丢弃——那不是性能信号)。
- * @param {number} intervalMs 上一帧到这一帧的间隔
- */
-export function noteFrameInterval(intervalMs) {
-    const n = Number(intervalMs)
-    if (!Number.isFinite(n) || n <= 0 || n > 1000) return
-    const state = ensureAdaptiveResolution()
-    state.intervals.push(n)
-    if (state.intervals.length > 90) state.intervals.shift()
+export function noteFrameInterval(intervalMs, options = {}) {
+    if (!isContinuousGap(intervalMs)) return false
+    if (options && options.moving === false) return false
+    return resolutionControllerFor(options.viewer)?.noteFrameInterval(intervalMs) ?? false
 }
 
-/**
- * 按最近帧间隔决定是否微调内部渲染分辨率。
- * 在 viewer 的 postRender 回调里每帧调一次;样本不足 / 时机未到都返回 false(没改)。
- * @param {Viewer} viewer
- * @param {number} moving 相机是否在动(在动时对卡顿更敏感,阈值更保守 —— 这里只记帧间隔,决策不依赖它)
- * @returns {boolean} 是否真的改了分辨率
- */
-/** 自适应分辨率的当前读数(DEV 面板用;模块级单源,不另算)。 */
-export function adaptiveResolutionStatus() {
-    const state = ensureAdaptiveResolution()
-    return {
-        scale: state.scale,
-        samples: state.intervals.length,
-        p95Ms: frameIntervalP95(state.intervals),
-    }
+export function adaptiveResolutionStatus(viewer) {
+    const status = resolutionControllerFor(viewer)?.status()
+    return status ? { ...status, actualScale: viewer.resolutionScale } : null
 }
 
 export function tickAdaptiveResolution(viewer) {
-    if (!viewer || !viewer.scene) return false
-    const state = ensureAdaptiveResolution()
-    const base = Number.isFinite(activePerformance.resolutionScale)
-        ? activePerformance.resolutionScale
-        : PERFORMANCE_DEFAULTS.resolutionScale
-    const floor = Number.isFinite(activePerformance.adaptiveFloor)
-        ? activePerformance.adaptiveFloor
-        : PERFORMANCE_DEFAULTS.adaptiveFloor
-    if (state.scale === null) state.scale = base
-    const p95 = frameIntervalP95(state.intervals)
-    if (p95 === null) return false
-    const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
-    const step = stepAdaptiveResolution({
-        p95Ms: p95,
-        currentScale: state.scale,
-        baseScale: base,
-        floorScale: floor,
-        nowMs,
-        hotSinceMs: state.hotSinceMs,
-        coldSinceMs: state.coldSinceMs,
-    })
-    state.hotSinceMs = step.hotSinceMs
-    state.coldSinceMs = step.coldSinceMs
-    if (!step.changed) return false
-    const changed = setRenderResolution(viewer, step.scale)
-    if (changed) state.scale = step.scale
-    return changed
+    return resolutionControllerFor(viewer)?.tick() ?? false
 }
 
 /**
@@ -397,13 +353,7 @@ export function applyPerformanceOptions(viewer, options = {}) {
     if (Number.isFinite(settings.msaaSamples)) {
         try { scene.msaaSamples = Math.max(0, Math.min(4, Math.round(settings.msaaSamples))) } catch { /* 老上下文忽略 */ }
     }
-    if (Number.isFinite(settings.resolutionScale)) {
-        const scale = Math.min(1, Math.max(0.5, settings.resolutionScale))
-        if (Math.abs(viewer.resolutionScale - scale) > 1e-6) {
-            viewer.resolutionScale = scale
-            viewer.resize()
-        }
-    }
+    resolutionControllerFor(viewer)?.configure(settings)
     try { scene.postProcessStages.fxaa.enabled = settings.fxaa !== false } catch { /* 无该级时忽略 */ }
 
     // 大气全屏散射:关掉后 globe 片元少算一大段,天空不再有蓝晕(外观退让点)。
@@ -457,12 +407,7 @@ export function setRenderResolution(viewer, scale) {
  * @returns {boolean} 是否真的改了
  */
 export function applyMotionResolution(viewer, moving) {
-    const base = Number.isFinite(activePerformance.resolutionScale)
-        ? activePerformance.resolutionScale : PERFORMANCE_DEFAULTS.resolutionScale
-    const movingScale = Number.isFinite(activePerformance.movingResolutionScale)
-        ? activePerformance.movingResolutionScale : PERFORMANCE_DEFAULTS.movingResolutionScale
-    // 判定与夹取都在 domain(单源、脱浏览器可断言);这里只做应用。
-    return setRenderResolution(viewer, motionResolution({ base, moving: movingScale, isMoving: moving === true }))
+    return resolutionControllerFor(viewer)?.setMoving(moving === true) ?? false
 }
 
 /** 叠加影像(票 21):本地精细切片画在底图之上,URL 模板里可用 `{reverseY}` 表示 TMS 的 y。 */
@@ -761,7 +706,8 @@ function createTilesetCullingController(scene, plans, instances, failed, onChang
                 // 预算口径:medium 与 fine 是**同时**可能驻留的,必须一起计入,
                 // 否则按 600 条 / 384MiB 选的区域实际占用会明显超出。
                 decodedGeometryBytes: (Number(r.decodedGeometryBytes) || 0) + (Number(r.decodedMediumBytes) || 0),
-                expectedContentUrls: [...absolute(r.contentUrls), ...mediumUrls],
+                contentCount: new Set([...absolute(r.contentUrls), ...mediumUrls]).size,
+                expectedContentUrls: [...new Set([...absolute(r.contentUrls), ...mediumUrls])],
                 expectedPreviewContentUrls: absolute(r.previewContentUrls),
                 expectedMediumContentUrls: mediumUrls,
             }        })
@@ -771,6 +717,10 @@ function createTilesetCullingController(scene, plans, instances, failed, onChang
             canRetain: () => !model.isDestroyed() && model.show,
             canStart: () => coarseRendered && model.tilesLoaded && model.show && !model.isDestroyed(),
             beforeRefresh: () => { cullingVolume = camera.frustum.computeCullingVolume(camera.positionWC, camera.directionWC, camera.upWC) },
+            coverageReady: entry => {
+                const view = camera.frustum.computeCullingVolume(camera.positionWC, camera.directionWC, camera.upWC)
+                return hasVisibleTileCoverage(entry.instance.root, tile => view.computeVisibility(tile.boundingSphere) !== Intersect.OUTSIDE)
+            },
             onFailure: (r, error) => { failed.push({ url: error.url || r.url, error: String(error.message || error) }); onChange?.({ ready: instances.length, instances, failed }) },
             distance: r => {
                 const result = contentAdmission(r.admissionSpheres,
@@ -793,6 +743,8 @@ function createTilesetCullingController(scene, plans, instances, failed, onChang
             requestRender: () => scene.requestRender(),
             // 票 61:前沿参数与预算可由 map.detail 覆盖;未配置即用 domain 默认档。
             frontier: activeDetail.frontier,
+            // 票 59 第二十四轮:距离分级细化阈值(精模提前下载)。
+            refineSchedule: activeDetail.refineSchedule,
             budget: activeDetail.budget,
             // 判"相机在动/静止"用真实位姿(米级取整),不用区域距离兜底 ——
             // 细微平移在区域距离上看不出来,会误判成静止而提前放大前沿。
@@ -909,6 +861,8 @@ export function applyViewModeLayers(viewer, tilesets, plan) {
 }
 
 export function destroyViewer(viewer) {
+    if (!viewer) return
+    resolutionControllers.delete(viewer)
     if (!viewer.isDestroyed()) viewer.destroy()
 }
 
@@ -2400,10 +2354,19 @@ export function pipelineSource(viewer) {
 }
 
 /** Currently loaded terrain height; null means no reliable surface sample yet. */
+/** 按坐标的地面高程缓存(票 98 第二轮):DOM 标记层每帧对每个标记都要取一次高程。 */
+const groundHeightCache = createHeightCache({ ttlMs: 3000, maxEntries: 5000 })
+
 export function groundHeightAt(viewer, lon, lat) {
     const globe = viewer && viewer.scene && viewer.scene.globe
     if (!globe || !Number.isFinite(lon) || !Number.isFinite(lat)) return null
-    const height = globe.getHeight(Cartographic.fromDegrees(lon, lat))
+    // 标记是静态的,相机一动却会每帧对每个标记重查一次四叉树 —— 纯重复。
+    // 与 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 Number.isFinite(height) ? height : null
 }
 
@@ -2591,10 +2554,18 @@ export function createCameraEngine(viewer) {
          * 走查里表现为「中心点没落在画布中心」的假失败)。这里显式加上画布原点,
          * 让消费方拿到的就是窗口坐标,别再靠语义猜。
          */
-        projectPivot(pivot) {
-            const at = SceneTransforms.worldToWindowCoordinates(scene, Cartesian3.fromDegrees(pivot.lon, pivot.lat, pivot.height || 0))
+        projectPivot(pivot, { visibleOnly = false } = {}) {
+            const world = Cartesian3.fromDegrees(pivot.lon, pivot.lat, pivot.height || 0)
+            if (visibleOnly) {
+                const toward = Cartesian3.subtract(world, camera.positionWC, projectionCache.toward)
+                if (Cartesian3.dot(toward, camera.directionWC) <= 0 || !occluderCached(viewer, camera).isPointVisible(world)) return null
+            }
+            const at = SceneTransforms.worldToWindowCoordinates(scene, world)
             if (!at) return null
-            const rect = viewer.canvas.getBoundingClientRect()
+            // 画布矩形**不能每次读**:DOM 标记层每帧对每个标记都调本函数,
+            // 每次 getBoundingClientRect 都是一次强制同步布局 —— 几百个标记就是几百次回流。
+            // 复用 projectMarkerEntity 那条路径的缓存(同样的 120ms 保鲜,只有 resize 才会变)。
+            const rect = canvasRectCached(viewer)
             return { x: at.x + rect.left, y: at.y + rect.top }
         },
 

+ 28 - 1
src/modules/cockpit/services/cockpitConfig.js

@@ -94,7 +94,7 @@ export function builtinConfig() {
 
             // 票 61:细模覆盖档(覆盖到多远、占多少预算、移动期收缩多狠)。
             // null 的每一项都交给 `domain/detailFrontier.js` 与 `services/regionLoading.js` 的默认档。
-            detail: { frontier: null, budget: null, tiers: null },
+            detail: { frontier: null, budget: null, tiers: null, refine: null },
 
             // 票 38:区内视距雾气。null/'' = 用 `domain/fog.js` 的**规格默认**
             // (18,000/42,000 m、#bdcdd6);即"不配就是规格定的那一套",与票 21 同一立场。
@@ -272,14 +272,41 @@ export function normalizeConfig(raw) {
             const frontier = map.detail.frontier
             const budget = map.detail.budget
             const tiers = map.detail.tiers
+            // 票 59 第二十四轮:细化提前量(近档阈值/带宽/边界)。同样是对象,走白名单保留。
+            const refine = map.detail.refine
+            // 2026-10-30 内存优化:细模 tileset 缓存上限(每个区域一个 tileset)。
+            // 这两项此前会被白名单静默丢掉,配置写了也不生效;一并把 fineScreenSpaceError /
+            // fineFoveated 这两个「细模专用档」也放行(它们本来就在 tilesetOptions 里声明可覆盖)。
+            const fineScreenSpaceError = map.detail.fineScreenSpaceError
+            const fineFoveated = map.detail.fineFoveated
+            const tilesetCacheBytes = map.detail.tilesetCacheBytes
+            const tilesetCacheOverflowBytes = map.detail.tilesetCacheOverflowBytes
             if (frontier !== undefined && frontier !== null && !isPlainObject(frontier)) issues.push('map.detail.frontier 应为对象,已忽略')
             else if (isPlainObject(frontier)) config.map.detail.frontier = { ...frontier }
             if (budget !== undefined && budget !== null && !isPlainObject(budget)) issues.push('map.detail.budget 应为对象,已忽略')
             else if (isPlainObject(budget)) config.map.detail.budget = { ...budget }
+            if (refine !== undefined && refine !== null && !isPlainObject(refine)) issues.push('map.detail.refine 应为对象,已忽略')
+            else if (isPlainObject(refine)) config.map.detail.refine = { ...refine }
             // 层级表是数组;原白名单只认 frontier/budget,会把它静默丢掉。
             // 结构合法性由 domain/lodTiers.js 统一归一化;配置层只负责"键真的能到引擎"。
             if (tiers !== undefined && tiers !== null && !Array.isArray(tiers)) issues.push('map.detail.tiers 应为数组,已忽略')
             else if (Array.isArray(tiers)) config.map.detail.tiers = tiers.map((tier) => isPlainObject(tier) ? { ...tier } : tier)
+            if (fineScreenSpaceError !== undefined && fineScreenSpaceError !== null) {
+                if (Number.isFinite(fineScreenSpaceError)) config.map.detail.fineScreenSpaceError = fineScreenSpaceError
+                else issues.push('map.detail.fineScreenSpaceError 应为数字,已忽略')
+            }
+            if (fineFoveated !== undefined && fineFoveated !== null) {
+                if (typeof fineFoveated === 'boolean') config.map.detail.fineFoveated = fineFoveated
+                else issues.push('map.detail.fineFoveated 应为布尔值,已忽略')
+            }
+            if (tilesetCacheBytes !== undefined && tilesetCacheBytes !== null) {
+                if (Number.isFinite(tilesetCacheBytes)) config.map.detail.tilesetCacheBytes = tilesetCacheBytes
+                else issues.push('map.detail.tilesetCacheBytes 应为数字,已忽略')
+            }
+            if (tilesetCacheOverflowBytes !== undefined && tilesetCacheOverflowBytes !== null) {
+                if (Number.isFinite(tilesetCacheOverflowBytes)) config.map.detail.tilesetCacheOverflowBytes = tilesetCacheOverflowBytes
+                else issues.push('map.detail.tilesetCacheOverflowBytes 应为数字,已忽略')
+            }
         } else issues.push('map.detail 应为对象,已忽略')
     }
 

+ 98 - 8
src/modules/cockpit/services/mapService.js

@@ -88,6 +88,8 @@ import {
 import { planSceneGrade } from '../domain/sceneGrade.js'
 import { attachModelDiagnostics } from './modelDiagnostics.js'
 import { tierAtDistance } from './tilesetOptions.js'
+// 票 98:读数包稳定引用(外壳 500ms 轮询写 ref;返回新对象会白触发整壳重渲染)。
+import { createStableReadout } from '../domain/readoutCache.js'
 import {
     HANDOVER_BLOCK,
     HANDOVER_PHASE,
@@ -570,13 +572,88 @@ export async function sampleTerrainGrid(lon, lat, dLon, dLat, n) {
  * 开发态把手:三类交互与「选中换中心」都能在控制台/走查里复现,不必点着鼠标盲试。
  * 只在 DEV 且浏览器里挂,离开 /cockpit 即摘掉;生产构建不含它。
  */
+/**
+ * 走查专用:`?frame=lon,lat,range[,pitch]` —— 引擎就绪后按精确距离落位。
+ *
+ * 五档 LOD 的可见差异只在特定距离带里成立(preview 600-1600m / medium 250-600m / fine <250m),
+ * 靠滚轮凑距离既不可复现也无法做对比证据。只跑一次,且只在 DEV 生效。
+ */
+/** 解析 `?frame=lon,lat,range[,pitch]`(只解析一次,结果缓存)。 */
+let frameParamSpec = null
+let frameParamParsed = false
+function readFrameParam() {
+    if (frameParamParsed) return frameParamSpec
+    frameParamParsed = true
+    if (typeof window === 'undefined' || !window.location) return null
+    const search = String(window.location.search || '')
+    const hashQuery = String(window.location.hash || '').split('?')[1] || ''
+    const raw = new URLSearchParams(search).get('frame')
+        || new URLSearchParams(hashQuery).get('frame')
+        || ''
+    const parts = raw.split(',').map(Number)
+    if (parts.length < 3 || !parts.slice(0, 3).every(Number.isFinite)) return null
+    frameParamSpec = { lon: parts[0], lat: parts[1], range: parts[2], pitch: Number.isFinite(parts[3]) ? parts[3] : -35 }
+    return frameParamSpec
+}
+
+/**
+ * 走查专用:按精确距离落位(票 59 第二十五轮实机验收)。
+ *
+ * 五档 LOD 的可见差异只在特定距离带成立(preview 600-1600m / medium 250-600m / fine <250m),
+ * 滚轮凑距离不可复现、也做不出可对比的证据。**每次 publishDevHook 都尝试**(幂等):
+ * 引擎可能晚于首次调用就绪,成功一次后置位。仅 DEV。
+ */
+let frameParamApplied = false
+// 落位之后**再补几次**:应用启动阶段还会 goHome / 进系统飞行把相机推回全球,
+// 只落位一次会被覆盖(实机走查踩到:截图仍是全球视角)。
+let frameParamPending = 0
+function applyFrameParamOnce() {
+    const spec = readFrameParam()
+    if (!spec) { frameParamApplied = true; return }
+    if (!instance || !instance.cameraControl) return
+    if (frameParamApplied && frameParamPending <= 0) return
+    const ok = instance.cameraControl.applyViewPreset(
+        { lon: spec.lon, lat: spec.lat, height: 0 },
+        { range: spec.range, pitch: spec.pitch, heading: 0 },
+        { immediate: true },
+    )
+    if (ok) {
+        frameParamApplied = true
+        // 再来 4 次(覆盖启动期的 home / 飞行落位),之后彻底停手。
+        if (frameParamPending <= 0) frameParamPending = 4
+        else frameParamPending -= 1
+    }
+}
 function publishDevHook() {
     const env = import.meta.env
     if (!env || !env.DEV || typeof window === 'undefined') return
+    // 走查专用:`?frame=lon,lat,range[,pitch]` 直接落位到指定距离(票 59 第二十五轮实机验收)。
+    // 五档 LOD 的差异只在特定距离带看得出来,滚轮凑距离不可复现。仅 DEV,不参与正式路径。
+    // eslint-disable-next-line no-use-before-define
+    applyFrameParamOnce()
     window.__cockpitMap = {
         getCameraState,
         setCameraFocus,
         clearCameraFocus,
+        /**
+         * 走查专用:把相机按**精确距离**放到某点上方(票 59 第二十五轮实机验收用)。
+         *
+         * 为什么需要它:五档 LOD 的差异只在**特定距离带**里才看得出来(preview 600–1600m、
+         * medium 250–600m、fine <250m)。靠滚轮凑距离不可复现,必须能精确指定 range。
+         * 只在 DEV 存在(与其他走查把手同一纪律),不参与正式路径。
+         *
+         * @param {{lon:number, lat:number, range:number, pitch?:number, heading?:number}} spec
+         */
+        frameAt: (spec) => {
+            if (!instance || !instance.cameraControl || !spec) return false
+            const pivot = { lon: Number(spec.lon), lat: Number(spec.lat), height: Number(spec.height) || 0 }
+            const hpr = {
+                range: Number(spec.range),
+                pitch: Number.isFinite(spec.pitch) ? spec.pitch : -35,
+                heading: Number.isFinite(spec.heading) ? spec.heading : 0,
+            }
+            return instance.cameraControl.applyViewPreset(pivot, hpr, { immediate: true })
+        },
         // 手感是主观的:DEV 下现场拖调阻尼时间常数(毫秒,0 = 不做阻尼)。
         setCameraDamping: (tauMs) => (instance && instance.cameraControl ? instance.cameraControl.setDampingTau(tauMs) : null),
         /** 「返回全球」飞行时长(毫秒);退出快慢是主观的,做成可现场拖调(见 `RETURN_FLIGHT_MS`)。 */
@@ -954,7 +1031,10 @@ async function bootEngine(record) {
             // 撑不住才降、撑得住就回升 —— 画质只在需要时让位)。
             try {
                 const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
-                if (record.lastFrameAt) engine.noteFrameInterval(nowMs - record.lastFrameAt)
+                // 只有**相机在动**(连续 rAF 节奏)的帧间隔才代表帧预算;静止期的帧是按需渲染。
+                // 真机走查实测:不区分时 p95 被按需空档抬到 399ms,自适应分辨率永久钉在下限 0.55。
+                const movingNow = record.cameraControl?.getState?.()?.settled === false
+                if (record.lastFrameAt) engine.noteFrameInterval(nowMs - record.lastFrameAt, { moving: movingNow, viewer })
                 record.lastFrameAt = nowMs
                 engine.tickAdaptiveResolution(viewer)
             } catch { /* 自适应失败不影响渲染 */ }
@@ -1033,9 +1113,20 @@ async function bootEngine(record) {
             // 票 61:细模覆盖前沿 —— 直接回答"现在覆盖到多远、为什么停在这"。
             detailFrontier: record.tilesetCulling?.frontierSnapshot?.() || [],
             // 票 61 第十九轮:自适应分辨率读数(scale / 样本数 / 帧间隔 p95)。
-            adaptiveResolution: engine.adaptiveResolutionStatus?.() || null,
+            adaptiveResolution: engine.adaptiveResolutionStatus?.(viewer) || null,
             camera: record.cameraControl?.getState?.() || null,
-        }), { isMoving: () => record.cameraControl?.getState?.()?.settled === false })
+        }), {
+            isMoving: () => record.cameraControl?.getState?.()?.settled === false,
+            pilotView: async (range) => {
+                const lon = 110.399905, lat = 28.459147
+                const height = await engine.sampleTerrainHeight(viewer, lon, lat)
+                if (!Number.isFinite(height) || instance !== record) throw new Error('试点地形高程未就绪')
+                setGlobeSpin(0)
+                const applied = record.cameraControl.applyViewPreset({ lon, lat, height }, { range, pitch: -40, heading: 0 }, { immediate: true })
+                viewer.scene.requestRender()
+                return { applied, lon, lat, height, actual: record.cameraControl.getState() }
+            },
+        })
         // 票 37:相机控件挂上后立刻把生效限位算一遍并下发 —— 进区状态可能在引擎就绪之前
         // 就已经定了(引导层先出现、配置先到手),这里补一次,免得第一次交互才生效。
         applyAreaLimits()
@@ -1773,9 +1864,8 @@ function applyAreaLimits() {
 }
 
 /** 当前生效的区域限位(走查与人工排查读它)。 */
-export function getAreaLimits() {
-    return { ...areaLimits }
-}
+// 票 98:外壳 500ms 轮询会写 ref —— 返回新对象会白触发整壳重渲染,所以包稳定引用。
+export const getAreaLimits = createStableReadout(() => ({ ...areaLimits }))
 
 /** 当前区域状态(`locked` / `global`)。 */
 export function getAreaState() {
@@ -2284,7 +2374,7 @@ function fogRanOnceForReadout() {
 }
 
 /** 当前雾的完整读数(走查与人工排查读它)。 */
-export function getFogState() {
+export const getFogState = createStableReadout(() => {
     const readout = fogReadout({
         state: fogLifecycle,
         degrade: fogDegrade,
@@ -2312,7 +2402,7 @@ export function getFogState() {
         uniformDiagnostics: fogHandle && typeof fogHandle.readUniformDiagnostics === 'function' ? fogHandle.readUniformDiagnostics() : null,
         perfResults: fogPerfResults.slice(),
     }
-}
+})
 
 /** 当前雾参数计划(配置 + 规格默认的合成结果)。 */
 export function getFogParams() {

+ 73 - 19
src/modules/cockpit/services/modelDiagnostics.js

@@ -1,22 +1,76 @@
-// Development-only, read-only browser evidence. No forced render or streaming changes.
+// Development-only evidence. Diagnostic reads never force a scene render.
 import { createMotionIntervalWindow } from '../domain/frameMotionWindow.js'
-export function attachModelDiagnostics(scene, host, readState, {now=()=>performance.now(),isMoving=()=>false}={}) {
-    const node=host.ownerDocument.createElement('details'),summary=host.ownerDocument.createElement('summary'),output=host.ownerDocument.createElement('output')
-    node.setAttribute('data-model-diagnostics','');summary.textContent='模型加载诊断(开发)'
-    node.style.cssText='position:absolute;bottom:4px;left:4px;z-index:8;max-width:420px;font:11px monospace;background:#061827dd;color:#bad4e0;padding:4px;pointer-events:auto'
-    output.style.cssText='display:block;white-space:pre-wrap;max-height:180px;overflow:auto'
-    output.setAttribute('aria-label','模型加载诊断读数');node.append(summary,output);host.appendChild(node)
-    let start=null,lastPaint=-Infinity,total=0
-    const durations=[]
-    const motion=createMotionIntervalWindow()
-    const add=(list,n)=>{list.push(n);if(list.length>120)list.shift()}
-    const stats=list=>{const sorted=[...list].sort((a,b)=>a-b);return{samples:list.length,p50Ms:sorted.length?+sorted[Math.floor(sorted.length*.5)].toFixed(2):null,p95Ms:sorted.length?+sorted[Math.min(sorted.length-1,Math.floor(sorted.length*.95))].toFixed(2):null}}
-    const pre=scene.preRender.addEventListener(()=>{start=now()})
-    function paint(){const state=readState();output.textContent=JSON.stringify({...state,renderEvents:total,renderSubmissionDuration:stats(durations),movingFrameIntervals:motion.snapshot(),gpuTimeMeasured:false,fpsDuringIdleMeaningful:false},null,2)}
-    const post=scene.postRender.addEventListener(()=>{const t=now();total++
-        if(!host.ownerDocument.hidden){if(start!==null)add(durations,t-start);motion.push(t,isMoving())}else{motion.markGap(t)}
-        start=null;if(t-lastPaint>=500){lastPaint=t;paint()}
+export function attachModelDiagnostics(scene, host, readState, { now = () => performance.now(), isMoving = () => false, pilotView = null } = {}) {
+    const doc = host.ownerDocument
+    const node = doc.createElement('details'), summary = doc.createElement('summary'), output = doc.createElement('output')
+    node.setAttribute('data-model-diagnostics', '')
+    summary.textContent = '模型加载诊断(开发)'
+    node.style.cssText = 'position:absolute;bottom:4px;left:4px;z-index:8;max-width:480px;font:11px monospace;background:#061827ee;color:#bad4e0;padding:4px;pointer-events:auto'
+    output.style.cssText = 'display:block;white-space:pre-wrap;max-height:180px;overflow:auto'
+    output.setAttribute('aria-label', '模型加载诊断读数')
+    node.append(summary, output); host.appendChild(node)
+    const query = new URLSearchParams(doc.defaultView.location.search)
+    const audit = query.get('lodAudit') === '1'
+
+    let start = null, lastPaint = -Infinity, total = 0, label = 'initial', requested = null, viewResult = null, disposed = false
+    const durations = [], records = []
+    const motion = createMotionIntervalWindow()
+    const add = (list, n) => { list.push(n); if (list.length > 240) list.shift() }
+    const stats = list => {
+        const sorted = [...list].sort((a, b) => a - b)
+        return { samples: sorted.length, p50Ms: sorted.length ? +sorted[Math.floor(sorted.length * .5)].toFixed(2) : null, p95Ms: sorted.length ? +sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * .95))].toFixed(2) : null, maxMs: sorted.length ? +sorted[sorted.length - 1].toFixed(2) : null, over50Ms: sorted.filter(n => n > 50).length }
+    }
+    function paint() {
+        const payload = { ...readState(), label, requested, viewResult, renderEvents: total, renderSubmissionDuration: stats(durations), movingFrameIntervals: motion.snapshot(), gpuTimeMeasured: false, fpsDuringIdleMeaningful: false, visibleDocument: !doc.hidden }
+        output.textContent = JSON.stringify(payload, null, 2)
+        if (audit) {
+            records.push(payload); if (records.length > 240) records.shift()
+            const regions = (payload.regions || []).map(r => ({ ...r, renderedTiles: undefined, renderedByLevel: (r.renderedTiles || []).reduce((a,t) => { a[t.level] = (a[t.level] || 0) + 1; return a }, {}), renderedExamples: (r.renderedTiles || []).slice(0, 3) }))
+            console.log('[LOD-AUDIT] ' + JSON.stringify({ label, requested, viewResult: viewResult && { applied: viewResult.applied, height: viewResult.height, error: viewResult.error }, renderEvents: total, sub: payload.renderSubmissionDuration, moving: payload.movingFrameIntervals, camera: payload.camera && { pivot: payload.camera.shownPivot, hpr: payload.camera.shownHpr, settled: payload.camera.settled }, regions: regions.map(r => ({ id: r.id, show: r.show, masked: r.coarseMasked, loaded: r.loaded, pending: r.pending, processing: r.processing, sse: r.maximumScreenSpaceError, effective: r.memoryAdjustedScreenSpaceError, memoryMB: r.memoryBytes && +(r.memoryBytes / 1048576).toFixed(1), firstContentMs: r.firstContentMs, rendered: r.renderedByLevel })) }))
+        }
+        return payload
+    }
+    if (audit && pilotView) {
+        node.open = true
+        node.style.left = '32%'; node.style.zIndex = '90'
+        const controls = doc.createElement('div')
+        for (const range of [6000, 3000, 1200, 450, 180, 320]) {
+            const button = doc.createElement('button')
+            button.textContent = '试点 ' + range + 'm'; button.dataset.lodAuditRange = String(range)
+            button.addEventListener('click', async () => {
+                label = 'view-' + range; requested = { range, atMs: now() }; viewResult = null
+                durations.length = 0; motion.reset()
+                button.disabled = true
+                try { viewResult = await pilotView(range) } catch (error) { viewResult = { error: String(error.message || error) } }
+                if (!disposed) { button.disabled = false; paint() }
+            })
+            controls.appendChild(button)
+        }
+        const exportButton = doc.createElement('button')
+        exportButton.textContent = '导出本次 LOD 观测'
+        exportButton.addEventListener('click', () => {
+            paint()
+            const a = doc.createElement('a'), url = URL.createObjectURL(new Blob([JSON.stringify(records, null, 2)], { type: 'application/json' }))
+            a.href = url; a.download = 'lod-audit.json'; a.click(); URL.revokeObjectURL(url)
+        })
+        controls.appendChild(exportButton); node.insertBefore(controls, output)
+    }
+    const pre = scene.preRender.addEventListener(() => { start = now() })
+    const post = scene.postRender.addEventListener(() => {
+        const t = now(); total++
+        if (!doc.hidden) {
+            if (start !== null) add(durations, t - start)
+            motion.push(t, isMoving())
+        } else {
+            // Hidden frames do not sample, but they must break "consecutive motion"
+            // so the first moving frame after returning cannot smuggle an idle gap in.
+            motion.markGap(t)
+        }
+        start = null
+        if (t - lastPaint >= 1000) { lastPaint = t; paint() }
     })
-    summary.addEventListener('click',paint);paint()
-    return()=>{pre();post();node.remove()}
+    // Audit mode also observes load progress while requestRenderMode is idle.
+    const timer = audit ? setInterval(paint, 2000) : null
+    summary.addEventListener('click', paint); paint()
+    return () => { disposed = true; if (timer) clearInterval(timer); pre(); post(); node.remove() }
 }

+ 64 - 33
src/modules/cockpit/services/regionLoading.js

@@ -22,18 +22,15 @@
  *   · **由近及远收敛**:镜头静止时半径按拍放大,直到 `maxRadius` 或预算耗尽。
  */
 import { stepDetailFrontier, DETAIL_FRONTIER_DEFAULTS } from '../domain/detailFrontier.js'
+// 票 59 第二十三轮:近景密度分档(纯函数,可离线断言)。
+import { selectByDistanceBands } from '../domain/detailBands.js'
+// 票 59 第二十四轮:按距离分级细化阈值(把 fine 的下载提前,消掉"近景突变")。
+import { refineThresholdForDistance } from '../domain/refineSchedule.js'
 
-/** 预算默认档(可被 `map.detail` 覆盖)。解码几何估算 ≈ 常驻内存量级。 */
+/** Global planning limits. These are not actual GPU memory and each tileset has its own Cesium cache. */
 export const REGION_BUDGET_DEFAULTS = {
-    /** 同时驻留的内容条数上限。 */
     maxContents: 1200,
-    /** 同时驻留的解码几何字节上限。
-     *
-     * 这是**规划估算**(manifest 里按"整区全量加载"记),不是实际 GPU 驻留 —— 真实驻留由
-     * Cesium 的 `cacheBytes`(默认 64 MiB)单独封顶。给 384 MiB 是因为实测 decoded 估算
-     * 约为磁盘/GPU 的 ~4.5 倍,384 MiB 规划 ≈ 覆盖主城近景一圈;真机 60fps 有 headroom。
-     * 低配机要更省就在 `map.detail.budget.maxDecodedBytes` 里调低。 */
-    maxDecodedBytes: 384 * 1024 * 1024,
+    maxDecodedBytes: 256 * 1024 * 1024,
 }
 
 /** 前沿默认档(继续沿用 domain 那份,避免两处各写一套数字)。 */
@@ -102,20 +99,13 @@ export function selectDetailRegions(regions, options = {}) {
         return finiteOr(a.id, 0) - finiteOr(b.id, 0)
     })
 
-    const selected = []
-    let contents = 0
-    let bytes = 0
-    for (const region of candidates) {
-        if (selected.length >= maxRegions) break
-        const nextContents = contents + finiteOr(region.contentCount, 0)
-        const nextBytes = bytes + finiteOr(region.decodedGeometryBytes, 0)
-        // 预算不够就**跳过**这一条,继续看后面的(后面可能更小、装得下)。
-        if (nextContents > maxContents || nextBytes > maxDecodedBytes) continue
-        selected.push(region.id)
-        contents = nextContents
-        bytes = nextBytes
-    }
-    return selected
+    // 分档挑选(见 domain/detailBands.js 的文件头:近景密集区必须让出外圈,
+    // 否则 CBWS202610150001 那种密集点会在 250m 内塞进 16 区/1028 片精模)。
+    return selectByDistanceBands(candidates, {
+        maxContents,
+        maxDecodedBytes,
+        maxRegions,
+    })
 }
 
 /**
@@ -180,6 +170,9 @@ export function createRegionLoadingController({
     maxConcurrentLoads = 2,
     // 票 61 第二十轮:每帧最多"首次显形"的区域数(见 syncVisibility 的显形配额)。
     showBudgetPerFrame = 1,
+    // 票 59 第二十四轮:距离分级的细化阈值(越近越早细化 ⇒ 精模提前下载)。
+    refineSchedule = null,
+    coverageReady = null,
 }) {
     // 显形配额(本帧剩余)。精模首次 show=true 会同步触发着色器编译 + 纹理上传
     // (每片 fine 14 种材质),同一帧全部 flip 就是加载期卡顿的单帧尖峰来源 —— 摊到多帧。
@@ -190,6 +183,7 @@ export function createRegionLoadingController({
         previewUrlIndex: new Set(r.expectedPreviewContentUrls || []),
         phase: 'pending', instance: null, loaded: new Set(), pending: 0, processing: 0,
         visible: false, visibleThisFrame: false, fineVisible: false, fineVisibleThisFrame: false,
+        renderedTiles: [], renderedTilesThisFrame: [], startedAt: null, firstContentAt: null,
         shown: false, retryAt: 0, distance: Infinity, centerDistance: Infinity, active: false,
         cached: false, awaitingRender: false, lastWantedAt: -Infinity,
         // previewSeen 与 loaded 是两份账:loaded 会被 tileUnload(视锥剔除/内存回收)扣减,
@@ -203,6 +197,8 @@ export function createRegionLoadingController({
         coveredAt: -Infinity,
     }))
     const byId = new Map(entries.map(e => [e.id, e]))
+    // Distances change only during refresh; reuse that order during rendered frames.
+    const distanceOrder = entries.slice()
 
     const budgetOptions = { ...REGION_BUDGET_DEFAULTS, ...(budget || {}) }
     const frontierOptions = { ...REGION_FRONTIER_DEFAULTS, ...(frontier || {}) }
@@ -241,6 +237,8 @@ export function createRegionLoadingController({
         e.cached = false; e.awaitingRender = false; e.lastWantedAt = -Infinity
         e.visible = e.visibleThisFrame = e.fineVisible = e.fineVisibleThisFrame = false
         e.pending = e.processing = 0
+        e.previewSeen.clear(); e.usableAt = e.coveredAt = -Infinity
+        e.renderedTiles = []; e.renderedTilesThisFrame = []
     }
 
     const contentCost = e => Math.max(0, finiteOr(e.contentCount, 0))
@@ -286,6 +284,7 @@ export function createRegionLoadingController({
         if (disposed || loading.size >= loadLimit) return
         loading.add(e)
         e.phase = 'initializing'
+        e.startedAt = now(); e.firstContentAt = null
         try {
             const model = await load(e)
             if (disposed || pipeline || !desired().includes(e.id)) { remove(model); e.phase = 'pending'; loading.delete(e); return }
@@ -296,6 +295,7 @@ export function createRegionLoadingController({
             e.detaches = []
             const listen = (event, fn) => { if (event) e.detaches.push(event.addEventListener(fn)) }
             listen(model.tileLoad, t => {
+                if (e.firstContentAt === null) e.firstContentAt = now()
                 const url = t.content?.url
                 if (url && (!e.contentUrlIndex || e.contentUrlIndex.has(url))) e.loaded.add(url)
                 if (url && e.previewUrlIndex.has(url)) e.previewSeen.add(url)
@@ -313,6 +313,7 @@ export function createRegionLoadingController({
                     // 也不是 medium**(票 91:medium 是独立中间级,不能算作细模)。
                     const isPreview = e.previewUrlIndex.has(url)
                     const isMedium = e.mediumUrlIndex ? e.mediumUrlIndex.has(url) : false
+                    e.renderedTilesThisFrame.push({ url, level: isPreview ? 'preview' : isMedium ? 'medium' : 'fine', triangles: t.content.trianglesLength })
                     if (e.contentUrlIndex?.has(url) && !isPreview && !isMedium) e.fineVisibleThisFrame = true
                 }
             })
@@ -371,8 +372,33 @@ export function createRegionLoadingController({
      *
      * @param {object} e 区域条目
      */
+    /**
+     * 按实时距离调该区域 tileset 的细化阈值(票 59 第二十四轮)。
+     *
+     * 目的:**把 fine 的下载提前**。实测 thr=56 时 fine 要到 251m 内才开始下载,
+     * 而该点附近 fine 上千片,注定赶不上相机 ⇒ 近景必先看到无窗粗模再突变。
+     * 距离越近阈值越小(20),fine 起点提前到 ~700m,替换发生在用户看不清细节的距离上。
+     *
+     * 只改 `maximumScreenSpaceError`:这是 Cesium 每帧都会读的字段,改它无需重建 tileset。
+     */
+    function applyRefineThreshold(e) {
+        // Preserve the resolved tier/SSE policy unless the deployment explicitly opts into a dynamic curve.
+        if (!refineSchedule || typeof refineSchedule !== 'object') return
+        const model = e.instance
+        if (!model) return
+        // Region admission distance differs from each rendered tile distance; this is a policy input, not a load-completion proof.
+        let liveDistance = e.distance
+        try { liveDistance = distance(e) } catch { /* 取不到就退回缓存值 */ }
+        const next = refineThresholdForDistance(liveDistance, refineSchedule)
+        // 只有真的变了才写(Cesium 会据此重算选中集合;每帧乱写会让它反复重算)。
+        if (Math.abs((model.maximumScreenSpaceError || 0) - next) < 0.5) return
+        model.maximumScreenSpaceError = next
+        requestRender()
+    }
+
     function syncVisibility(e) {
         if (!e.instance || e.cached) return
+        applyRefineThreshold(e)
         if (e.awaitingRender) return
         const arrived = arrivedOf(e)
         const at = now()
@@ -419,12 +445,11 @@ export function createRegionLoadingController({
      */
     function usableOf(e) {
         if (!e.instance) return false
-        // 「见过至少一片 preview」即可显形:
-        //   · 不是「全部到齐」—— 全部到齐读的是会被剔除扣减的 loaded,一片被剔除就永久白盒;
-        //   · 不是「0 片也行」—— 还没见过任何 preview 的区域显形就是露粗模底下的空洞。
-        // 这是正确性而不是性能问题:它决定粗模遮罩什么时候撤,撤早了露洞、撤晚了白盒。
-        const expectsPreview = !!e.expectedPreviewContentUrls?.length
-        if (expectsPreview && e.previewSeen.size === 0) return false
+        // Engine path proves coverage per visible branch, including loaded descendant replacements.
+        // This allows progressive refinement without waiting for all fine tiles or dropping uncovered coarse geometry.
+        if (typeof coverageReady === 'function') return coverageReady(e) === true
+        // Metadata-free callers have no branch tree, so initial readiness must stay conservative.
+        if (e.expectedPreviewContentUrls?.length && e.previewSeen.size === 0) return false
         return arrivedOf(e)
     }
 
@@ -482,6 +507,7 @@ export function createRegionLoadingController({
         if (!pipeline) {
             beforeRefresh()
             for (const e of entries) e.distance = distance(e)
+            distanceOrder.sort((a, b) => (a.distance - b.distance) || 0)
             advanceFrontier()
         }
         const wanted = desired()
@@ -508,8 +534,7 @@ export function createRegionLoadingController({
         if (opts.skipVisibility !== true) {
             // 定时刷新可能紧接 postRender,仍属于同一个待渲染帧,不能重新发放显形配额。
             // 与 onRender 共用剩余配额;只有下一次 onRender 才开启下一帧额度。
-            const ordered = [...entries].sort((a, b) => (a.distance - b.distance) || 0)
-            for (const e of ordered) {
+            for (const e of distanceOrder) {
                 if (!wanted.includes(e.id) || e.phase === 'failed') continue
                 if (e.instance) syncVisibility(e)
             }
@@ -541,10 +566,10 @@ export function createRegionLoadingController({
             // 真正贵的是 refresh() 里的排序/分配/装载决策,那个才需要节流。
             // 显形配额重置(与 refresh 同一纪律:近处先显形)。
             showBudgetRemaining = Math.max(1, Math.trunc(finiteOr(showBudgetPerFrame, 1)))
-            const ordered = [...entries].sort((a, b) => (a.distance - b.distance) || 0)
-            for (const e of ordered) {
+            for (const e of distanceOrder) {
                 e.visible = e.visibleThisFrame; e.visibleThisFrame = false
                 e.fineVisible = e.fineVisibleThisFrame; e.fineVisibleThisFrame = false
+                e.renderedTiles = e.renderedTilesThisFrame; e.renderedTilesThisFrame = []
                 if (!e.instance || e.cached) continue
                 e.awaitingRender = false
                 // **显形与遮罩必须在每帧路径上完成,不能留在节流分支里**:
@@ -585,6 +610,12 @@ export function createRegionLoadingController({
                 mediumContents: e.expectedMediumContentUrls?.length || 0,
                 fineLoaded: [...e.loaded].filter(url => e.contentUrlIndex?.has(url) && !e.previewUrlIndex.has(url) && !(e.mediumUrlIndex && e.mediumUrlIndex.has(url))).length,
                 fineVisibleLastRender: e.fineVisible, coarseMasked: e.shown,
+                show: e.instance?.show === true, tilesLoaded: e.instance?.tilesLoaded === true,
+                maximumScreenSpaceError: e.instance?.maximumScreenSpaceError,
+                memoryAdjustedScreenSpaceError: e.instance?.memoryAdjustedScreenSpaceError,
+                memoryBytes: e.instance?.totalMemoryUsageInBytes,
+                firstContentMs: e.firstContentAt === null ? null : e.firstContentAt - e.startedAt,
+                renderedTiles: e.renderedTiles,
             }))
         },
         detach() {

+ 23 - 2
src/modules/cockpit/services/tilesetOptions.js

@@ -58,6 +58,15 @@ const DETAIL_DEFAULTS = {
     foveated: false,
     /** 层级表(票 61 第十六轮)。五级默认见 domain/lodTiers.js。 */
     tiers: null,
+    /**
+     * 2026-10-30 内存优化:细模是**每个区域一个 tileset**,每个 tileset 各自持有一份
+     * Cesium 内容缓存。旧默认沿用粗模的 64MB + 32MB,区域内同时驻留多个区域时
+     * 缓存上限会按区域数线性放大(密集点可同时有十几个区域)。
+     * 单个区域只是几百米见方,24MB + 8MB 足够覆盖它的 fine 内容;
+     * 需要更高保真/更少回流的部署可经 `map.detail.tilesetCacheBytes` 调回。
+     */
+    cacheBytes: 24 * 1024 * 1024,
+    maximumCacheOverflowBytes: 8 * 1024 * 1024,
 }
 
 const detailTuning = {
@@ -101,6 +110,17 @@ export function setDetailTilesetPerformance(options = {}) {
     // 层级表:传数组就归一化(非法整表退回默认),传 null 就清掉(回落到显式阈值)。
     if (next.tiers === null || next.tiers === undefined) detailTuning.tiers = null
     else detailTuning.tiers = normalizeLodTiers(next.tiers)
+    // 细模缓存:null/undefined 回默认;给数则夹在安全区间(不小于 16MiB,防回流抖动)。
+    if (next.tilesetCacheBytes === null || next.tilesetCacheBytes === undefined) {
+        detailTuning.cacheBytes = DETAIL_DEFAULTS.cacheBytes
+    } else if (Number.isFinite(next.tilesetCacheBytes)) {
+        detailTuning.cacheBytes = Math.max(16 << 20, Math.min(256 << 20, next.tilesetCacheBytes))
+    }
+    if (next.tilesetCacheOverflowBytes === null || next.tilesetCacheOverflowBytes === undefined) {
+        detailTuning.maximumCacheOverflowBytes = DETAIL_DEFAULTS.maximumCacheOverflowBytes
+    } else if (Number.isFinite(next.tilesetCacheOverflowBytes)) {
+        detailTuning.maximumCacheOverflowBytes = Math.max(0, Math.min(64 << 20, next.tilesetCacheOverflowBytes))
+    }
 }
 
 /** 当前生效的层级表(没配置就是默认五级)。给诊断与闸门读同一份。 */
@@ -148,8 +168,9 @@ export function detailTilesetOptions(plan = {}) {
         foveatedTimeDelay: 0.6,
         foveatedMinimumScreenSpaceErrorRelaxation: 0,
         maximumScreenSpaceError: scaled,
-        cacheBytes: tuning.cacheBytes,
-        maximumCacheOverflowBytes: tuning.maximumCacheOverflowBytes,
+        // 细模用**自己的**缓存档(每个区域一个 tileset);全局降载仍可把它压得更低。
+        cacheBytes: Math.min(detailTuning.cacheBytes, tuning.cacheBytes),
+        maximumCacheOverflowBytes: Math.min(detailTuning.maximumCacheOverflowBytes, tuning.maximumCacheOverflowBytes),
     }
 }
 

+ 16 - 5
tools/city-massing/verify-admission-pose-sweep.mjs

@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
 import {readFileSync,writeFileSync} from 'node:fs'
 import {resolve,join} from 'node:path'
 import {Matrix4,Matrix3,Quaternion,Cartesian3,BoundingSphere,Transforms,PerspectiveFrustum,Intersect} from 'cesium'
-import {contentAdmission,selectDetailRegions} from '../../src/modules/cockpit/services/regionLoading.js'
+import {contentAdmission,selectDetailRegions,REGION_BUDGET_DEFAULTS} from '../../src/modules/cockpit/services/regionLoading.js'
 const root=resolve('tools/city-massing/city/cad-bim'),v6=JSON.parse(readFileSync(join(root,'city-regions-v6/manifest.json'))),v7=JSON.parse(readFileSync(join(root,'city-regions-v7/manifest.json'))),transform=Matrix4.fromArray(v7.transform)
 const pivot={lon:110.39028164000001,lat:28.4436787,height:111.57951503970997},radians=n=>n*Math.PI/180
 function camera(heading,pitch,range){
@@ -20,12 +20,23 @@ for(const range of [12000,1560.344530541111,469.96674118784495,180,120])for(cons
  const visible=s=>volume.computeVisibility(s)!==Intersect.OUTSIDE,dist=s=>Cartesian3.distance(pose.position,s.center)
  function candidates(manifest,useContent){return manifest.regions.map(r=>{const actual=(v7.regions[r.id].admissionBounds).map(sphere),bounds=(useContent?r.admissionBounds:[r.bounds]).map(sphere),admission=contentAdmission(bounds,visible,dist);return{...r,...admission,retryAt:0,active:false,actualVisibleContents:actual.filter(visible).length}})}
  const old=candidates(v6,false),next=candidates(v7,true),selected=selectDetailRegions(next),records=selected.map(id=>next.find(r=>r.id===id)),oldSelected=selectDetailRegions(old)
- assert.ok(records.every(r=>r.actualVisibleContents>0));assert.ok(records.length<=2&&records.reduce((s,r)=>s+r.contentCount,0)<=180&&records.reduce((s,r)=>s+r.decodedGeometryBytes,0)<=96*1024*1024)
+ assert.ok(records.every(r=>r.actualVisibleContents>0))
+ // 票 61:选择器从"最多 2 个 / 180 条 / 96MiB"改为**半径 + 预算**驱动(控制量是覆盖半径,
+ // 不再是区域个数 —— 按个数切会在同距离相邻区域之间留下硬边,正是"一半精模一半火柴盒"的成因)。
+ // 这里改为断言新口径的不变量:不超新预算、且选中的都是"真的有可见内容"的区域。
+ assert.ok(records.reduce((s,r)=>s+r.contentCount,0)<=REGION_BUDGET_DEFAULTS.maxContents&&records.reduce((s,r)=>s+r.decodedGeometryBytes,0)<=REGION_BUDGET_DEFAULTS.maxDecodedBytes)
  assert.deepEqual(selectDetailRegions(next,{pipeline:true}),[])
  samples.push({heading,pitch,range,cameraECEF:{position:[pose.position.x,pose.position.y,pose.position.z],direction:[pose.direction.x,pose.direction.y,pose.direction.z],up:[pose.up.x,pose.up.y,pose.up.z]},oldRegions:oldSelected,oldSelectedWithNoActualVisibleContent:oldSelected.filter(id=>!old.find(r=>r.id===id).actualVisibleContents),newRegions:selected,newVisibleContentBounds:records.map(r=>({id:r.id,visibleBounds:r.actualVisibleContents,distance:r.distance})),contents:records.reduce((s,r)=>s+r.contentCount,0),decodedEstimate:records.reduce((s,r)=>s+r.decodedGeometryBytes,0),pipelineRegions:0})
 }
 const recorded=samples.find(s=>s.heading===0&&s.pitch===-55&&s.range===469.96674118784495)
-assert.deepEqual(recorded.oldSelectedWithNoActualVisibleContent,[64,39],'recorded-pose fixture must reproduce old empty admissions')
-assert.deepEqual(recorded.newRegions,[78,77],'actual-content admission selects the nearby visible bounds')
+// 票 61:这一条原本钉住"旧选择器在该机位选出 2 个**实际没有可见内容**的区域"。
+// 新选择器按半径覆盖、且以内容表面距排序,选出的集合必然不同 —— 该 fixture 的**旧语义**
+// 不再可用。保留其真实意图(新口径下不得选到"零可见内容"的区域),改为不变量断言:
+for(const row of recorded.newVisibleContentBounds)assert.ok(row.visibleBounds>0,'no selected region may have zero visible content (id '+row.id+')')
+// 票 61:旧断言写死 [78,77](当时 maxRegions=2 的产物)。新口径按半径覆盖,
+// 同一机位会把附近**所有**在覆盖范围内的可见内容区都选中 —— 改为验证不变量。
+assert.ok(recorded.newRegions.includes(78)&&recorded.newRegions.includes(77),'actual-content admission still selects the nearby visible bounds')
+assert.ok(recorded.newRegions.length>=2,'radius coverage must not shrink below the old pair at this pose')
+for(const row of recorded.newVisibleContentBounds)assert.ok(row.visibleBounds>0,'every selected region must have actually visible content (id '+row.id+')')
 const report={passed:true,recordedPivot:pivot,samples,frustumAssumption:'60 degree FOV, 1440x810 canvas; actual browser FOV not captured',activeHistoryApplied:false,renderingValidated:false,gpuMeasured:false,scope:'Cesium HPR/frustum math, selected content-bounds visibility and declared aggregate budgets only'}
-writeFileSync(process.argv[2]||'.scratch/cockpit-shell-panel-layout/verify/artifacts/t35/admission-pose-sweep-v7.json',JSON.stringify(report,null,2));console.log(JSON.stringify({passed:true,poses:samples.length,oldEmptySelections:samples.reduce((s,r)=>s+r.oldSelectedWithNoActualVisibleContent.length,0),newEmptySelections:0,recordedHeading0:samples.filter(s=>s.heading===0&&s.pitch===-55),renderingValidated:false}))
+writeFileSync(process.argv[2]||'.scratch/cockpit-shell-panel-layout/verify/artifacts/t35/admission-pose-sweep-v7.json',JSON.stringify(report,null,2));console.log(JSON.stringify({passed:true,poses:samples.length,oldEmptySelections:samples.reduce((s,r)=>s+r.oldSelectedWithNoActualVisibleContent.length,0),newEmptySelections:0,recordedHeading0:samples.filter(s=>s.heading===0&&s.pitch===-55),renderingValidated:false}))

+ 7 - 1
tools/city-massing/verify-cell-coarse-city.mjs

@@ -4,7 +4,7 @@ import { resolve, join } from 'node:path'
 import { parseGlb } from './instance-repeated-meshes.mjs'
 import { normalizeConfig } from '../../src/modules/cockpit/services/cockpitConfig.js'
 import { planTilesets } from '../../src/modules/cockpit/domain/mapLayers.js'
-import { tilesetOptions } from '../../src/modules/cockpit/services/tilesetOptions.js'
+import { tilesetOptions, setTilesetPerformance } from '../../src/modules/cockpit/services/tilesetOptions.js'
 const dir = resolve(process.argv[2]),tree=JSON.parse(readFileSync(join(dir,'tileset.json'))),report=JSON.parse(readFileSync(join(dir,'manifest.json')))
 assert.equal(tree.root.content,undefined,'empty root must not wait for entire city')
 assert.equal(tree.root.viewerRequestVolume,undefined)
@@ -35,9 +35,15 @@ assert.equal(ids.size,30172);assert.equal(seen.size,expected.size);assert.equal(
 const config=normalizeConfig({map:{tilesets:[{url:'/gis/example',progressiveRefinement:true,maximumScreenSpaceError:96}]}})
 assert.deepEqual(config.issues,[])
 const planned=planTilesets(config.config.map)[0],options=tilesetOptions(planned)
+// 票 35(2026-10-23 维护者反馈后回退):连续/离散仍由 `progressiveRefinement` 决定,
+// 默认不强制离散 —— 离散档只是运维可选项(`map.performance.discreteLod: true`)。
 assert.equal(planned.progressiveRefinement,true);assert.equal(options.skipLevelOfDetail,false);assert.equal(options.maximumScreenSpaceError,96)
 assert.equal(options.preloadWhenHidden,false);assert.equal(options.preloadFlightDestinations,false)
 assert.equal(options.cacheBytes,64*1024*1024);assert.equal(options.maximumCacheOverflowBytes,32*1024*1024)
+// 运维可显式切离散档(并留在该档直到复位)。
+setTilesetPerformance({discreteLod:true})
+assert.equal(tilesetOptions(planned).skipLevelOfDetail,true,'discrete opt-in still available')
+setTilesetPerformance({discreteLod:null})
 assert.equal(tilesetOptions(planTilesets(normalizeConfig({map:{tilesets:[{url:'old'}]}}).config.map)[0]).skipLevelOfDetail,true,'old deployments unchanged')
 const bad=normalizeConfig({map:{tilesets:[{url:'x',progressiveRefinement:'yes'}]}})
 assert.ok(bad.issues.some(s=>s.includes('progressiveRefinement')));assert.equal(planTilesets(bad.config.map)[0].progressiveRefinement,false)

+ 68 - 16
tools/city-massing/verify-region-loading.mjs

@@ -1,38 +1,78 @@
 import assert from 'node:assert/strict'
-import {selectDetailRegions,createRegionLoadingController} from '../../src/modules/cockpit/services/regionLoading.js'
+import {selectDetailRegions,createRegionLoadingController,REGION_BUDGET_DEFAULTS} from '../../src/modules/cockpit/services/regionLoading.js'
 import {removeSceneTileset} from '../../src/modules/cockpit/services/tilesetCleanup.js'
 function event(){const f=new Set();return{addEventListener(fn){f.add(fn);return()=>f.delete(fn)},raise(...args){for(const fn of f)fn(...args)}}}
 function model(){return {tileLoad:event(),tileUnload:event(),tileVisible:event(),tileFailed:event(),loadProgress:event(),tilesLoaded:false}}
 const regions=Array.from({length:113},(_,id)=>({id,contentCount:30+(id%4)*20,retryAt:0,distance:10+id,active:false,url:'region'+id}))
-const wanted=selectDetailRegions(regions);assert.equal(wanted.length,2);assert.ok(wanted.reduce((s,i)=>s+regions[i].contentCount,0)<=180)
+// 票 61:选择器改为"半径 + 预算"驱动(控制量是覆盖半径,不再是区域个数)。
+// 旧断言把 maxRegions=2 / 700m / 180 条 / 96MiB 这套数字写死了;那套口径正是
+// "同距离一半精模一半火柴盒"的成因,不能继续钉住。这里改为钉**新口径的不变量**:
+const wanted=selectDetailRegions(regions,{radius:700,now:0})
+assert.ok(wanted.length>2,'radius coverage must not collapse back to a fixed pair')
+assert.ok(wanted.every((id,i,a)=>i===0||regions[id].distance>=regions[a[i-1]].distance),'must stay ordered near-to-far')
+assert.ok(wanted.reduce((s,i)=>s+regions[i].contentCount,0)<=REGION_BUDGET_DEFAULTS.maxContents,'must respect the content budget')
 assert.deepEqual(selectDetailRegions(regions,{pipeline:true}),[])
-assert.deepEqual(selectDetailRegions(regions.map(r=>({...r,distance:1000}))),[])
-const memory=selectDetailRegions(regions.map(r=>({...r,decodedGeometryBytes:60*1024*1024})));assert.equal(memory.length,1)
-const retained=selectDetailRegions(regions.map(r=>({...r,active:r.id===20})));assert.equal(retained[0],20)
-assert.deepEqual(selectDetailRegions([{id:0,active:true,distance:500,contentCount:20,retryAt:0},{id:1,active:false,distance:10,contentCount:20,retryAt:0}],{maxRegions:1}),[1],'new near region must outrank old distant active region')
+assert.deepEqual(selectDetailRegions(regions.map(r=>({...r,distance:1000})),{radius:700}),[],'beyond the frontier radius nothing is selected')
+// 预算放大:单区 60MiB 在 160MiB 预算下可以同时驻留(旧口径 96MiB 只容得下一个)
+const memory=selectDetailRegions(regions.map(r=>({...r,decodedGeometryBytes:60*1024*1024})),{radius:700,now:0,maxDecodedBytes:160*1024*1024})
+assert.ok(memory.length>=1,'single large region still selectable within budget')
+// 迟滞:已加载区域在 radius*retainFactor 内**仍然可选**,超出才释放。
+// 用单区域夹具,避免被"其它更近的区域先占满预算"干扰(那是选择器的正常行为,不是迟滞问题)。
+const insideHysteresis=selectDetailRegions([{id:20,distance:50,centerDistance:60,contentCount:1,decodedGeometryBytes:1,retryAt:0,active:true}],{radius:40,now:0,retainFactor:1.4})
+assert.deepEqual(insideHysteresis,[20],'active region inside radius*retainFactor is retained')
+const beyondHysteresis=selectDetailRegions([{id:20,distance:90,centerDistance:100,contentCount:1,decodedGeometryBytes:1,retryAt:0,active:true}],{radius:40,now:0,retainFactor:1.4})
+assert.deepEqual(beyondHysteresis,[],'active region beyond radius*retainFactor is released')
+const notActive=selectDetailRegions([{id:20,distance:50,centerDistance:60,contentCount:1,decodedGeometryBytes:1,retryAt:0,active:false}],{radius:40,now:0,retainFactor:1.4})
+assert.deepEqual(notActive,[],'hysteresis applies only to already-active regions')
+assert.deepEqual(selectDetailRegions([{id:0,active:true,distance:500,contentCount:20,retryAt:0},{id:1,active:false,distance:10,contentCount:20,retryAt:0}],{radius:1000,maxRegions:1}),[1],'new near region must outrank old distant active region')
 let started=[],added=[],removed=[],ticks=0
 const models=new Map(),controller=createRegionLoadingController({regions:[{id:0,contentCount:2,expectedContentUrls:['a','b'],retryAt:0,url:'zero'},{id:1,contentCount:2,retryAt:0,url:'one'}],
     load:async r=>{started.push(r.id);const m=model();models.set(r.id,m);return m},add:m=>added.push(m),remove:m=>removed.push(m),distance:()=>10,now:()=>ticks,
     setTimer:()=>1,clearTimer:()=>{},requestRender:()=>{}})
-controller.refresh();await Promise.resolve();await Promise.resolve();assert.deepEqual(started,[0],'only one initial stream')
+// 票 61:串行单槽 → **有限并发**(默认 2)。旧断言 "only one initial stream" 钉的是
+// maxRegions=2 时代的单槽纪律;新语义下一个并发档内可以同时开装,这正是把
+// "同距离区域排队数十秒才变精模"压掉的手段。这里钉新的不变式:不超过并发档。
+controller.refresh();await Promise.resolve();await Promise.resolve();assert.ok(started.length<=2,'concurrent loads stay within the configured limit')
+assert.deepEqual(started.slice().sort(),[0,1],'both admitted regions start within one tick')
 const a=models.get(0);assert.equal(a.show,false);assert.equal(a.preloadWhenHidden,true)
 a.tileLoad.raise({content:{url:'unrelated'}})
-a.tileLoad.raise({content:{url:'a'}});a.tilesLoaded=true;controller.onRender();assert.equal(controller.detailReady(0),false,'one missing content preserves coarse');assert.equal(a.show,false);assert.deepEqual(started,[0])
+// 票 61:到位判据改为 Cesium 口径(`tilesLoaded && pending===0 && processing===0`),
+// 不再用 `loaded.size === contentCount` —— 后者会被 tileUnload 扣减而永久卡住并发槽。
+// 因此"少一条内容"不再等于"没到位":`tilesLoaded` 才是权威。这里改判该语义。
+a.tileLoad.raise({content:{url:'a'}});a.tilesLoaded=true;controller.onRender()
+// 到位(Cesium 口径)后允许显形;但**这一帧还没渲染过**,所以粗模遮罩仍保留。
+assert.equal(controller.detailReady(0),false,'must observe rendered detail before unmasking coarse')
+assert.ok(started.includes(0),'region 0 must have started')
 a.tileLoad.raise({content:{url:'b'}});controller.onRender();await Promise.resolve();await Promise.resolve()
 assert.equal(a.show,true);assert.equal(controller.detailReady(0),false,'must observe rendered detail after hidden preload')
 a.tileVisible.raise({content:{trianglesLength:1}});controller.onRender()
-assert.equal(controller.detailReady(0),true);controller.refresh();assert.equal(controller.detailReady(0),true,'timers must not unmask between rendered frames');assert.deepEqual(started,[0,1],'second admitted only after actual complete first region')
-a.tileUnload.raise({content:{url:'b'}});assert.equal(controller.detailReady(0),false,'eviction must restore coarse')
+assert.equal(controller.detailReady(0),true);controller.refresh();assert.equal(controller.detailReady(0),true,'timers must not unmask between rendered frames')
+assert.ok(started.includes(1),'second region admitted (bounded concurrency, not single slot)')
+// 票 61 第十五轮:遮罩带 900ms 保持期(防"精粗模反复切换"的闪烁)。
+// 所以**瞬时**剔除不再立刻掀遮罩;持续失效才回退。这两条都要断。
+a.tileUnload.raise({content:{url:'b'}})
+assert.equal(controller.detailReady(0),true,'eviction within the hold window must NOT flash coarse back')
+ticks+=1200
+controller.onRender()
+assert.equal(controller.detailReady(0),false,'sustained eviction must restore coarse after the hold window')
 a.tileLoad.raise({content:{url:'b'}});a.tileVisible.raise({content:{trianglesLength:1}});controller.onRender();assert.equal(controller.detailReady(0),true)
 controller.setPipeline(true);assert.equal(controller.snapshot().length,0);assert.equal(controller.detailReady(0),false);assert.equal(removed.length,2,'pipeline releases all fine regions')
 controller.setPipeline(false);await Promise.resolve();await Promise.resolve();assert.ok(controller.snapshot().length<=2)
 const fresh=models.get(0);fresh.tileFailed.raise({message:'test failure'});controller.refresh();assert.equal(controller.detailReady(0),false)
 controller.detach();
 let gate=false,front=0,frustumStarts=[],frustumRemoved=[]
-const frustum=createRegionLoadingController({regions:[{id:0,contentCount:1,retryAt:0},{id:1,contentCount:1,retryAt:0}],load:async r=>{frustumStarts.push(r.id);return model()},add:()=>{},remove:m=>frustumRemoved.push(m),canStart:()=>gate,distance:r=>r.id===front?10:Infinity,setTimer:()=>1,clearTimer:()=>{}})
+// 视锥从 A 转向 B:旧区释放后,移动中的新近区也必须在单槽限制内推进。
+// 低配机记录证明“必须等完全停手”会让近景长期只有粗模,故不再锁定旧零开装策略。
+let clock=0
+const frustum=createRegionLoadingController({regions:[{id:0,contentCount:1,retryAt:0},{id:1,contentCount:1,retryAt:0}],load:async r=>{frustumStarts.push(r.id);return model()},add:()=>{},remove:m=>frustumRemoved.push(m),canStart:()=>gate,distance:r=>r.id===front?10:Infinity,now:()=>clock,setTimer:()=>1,clearTimer:()=>{}})
 frustum.refresh();await Promise.resolve();assert.deepEqual(frustumStarts,[],'coarse must finish before fine starts')
 gate=true;frustum.refresh();await Promise.resolve();await Promise.resolve();assert.deepEqual(frustumStarts,[0])
-front=1;frustum.refresh();await Promise.resolve();await Promise.resolve();assert.deepEqual(frustumStarts,[0,1]);assert.equal(frustumRemoved.length,1,'offscreen region must release serial slot')
+front=1;frustum.refresh();await Promise.resolve();await Promise.resolve()
+assert.deepEqual(frustumStarts,[0,1],'newly visible region starts during movement after the old slot is released')
+assert.equal(frustum.snapshot().length,1,'only the newly admitted region remains active')
+clock=600;frustum.refresh();await Promise.resolve();await Promise.resolve()
+assert.deepEqual(frustumStarts,[0,1],'settling must not restart an already streaming region')
+assert.ok(frustumRemoved.length>=1,'offscreen region must release its slot')
 frustum.detach();
 // Late fromUrl completion after view switch must be destroyed, never added.
 let resolveLoad,lateAdded=0,lateRemoved=0
@@ -40,10 +80,14 @@ const late=createRegionLoadingController({regions:[{id:0,contentCount:1,retryAt:
 late.refresh();late.setPipeline(true);resolveLoad(model());await Promise.resolve();await Promise.resolve();assert.equal(lateAdded,0);assert.equal(lateRemoved,1);late.detach()
 let ranked=[]
 const ranking=createRegionLoadingController({regions:[{id:0,contentCount:1,retryAt:0},{id:1,contentCount:1,retryAt:0}],load:async r=>{ranked.push(r.id);return model()},add:()=>{},remove:()=>{},distance:r=>r.id?1:100,setTimer:()=>1,clearTimer:()=>{}})
-ranking.refresh();await Promise.resolve();await Promise.resolve();assert.deepEqual(ranked,[1],'load must follow nearest priority, not manifest order');ranking.detach()
+// 票 61:并发档默认 2,所以"最近的先开"不再等于"只有最近的在开"。断言改为:
+// **最近的那个必须先被开装**(顺序语义),并发档内允许同时开别的。
+ranking.refresh();await Promise.resolve();await Promise.resolve()
+assert.equal(ranked[0],1,'load must follow nearest priority, not manifest order')
+assert.ok(ranked.length<=2,'concurrent loads stay within the limit');ranking.detach()
 let previewStarts=[],previewModels=new Map()
 const previews=createRegionLoadingController({regions:[{id:0,retryAt:0,contentCount:3,expectedContentUrls:['parent','fineA','fineB'],expectedPreviewContentUrls:['parent']},{id:1,retryAt:0,contentCount:1}],
-    load:async r=>{previewStarts.push(r.id);const m=model();previewModels.set(r.id,m);return m},add:()=>{},remove:()=>{},distance:()=>1,setTimer:()=>1,clearTimer:()=>{}})
+    load:async r=>{previewStarts.push(r.id);const m=model();previewModels.set(r.id,m);return m},add:()=>{},remove:()=>{},distance:()=>1,now:()=>ticks,setTimer:()=>1,clearTimer:()=>{}})
 previews.refresh();await Promise.resolve();await Promise.resolve()
 const parent=previewModels.get(0);parent.tileLoad.raise({content:{url:'parent'}});parent.tilesLoaded=true;previews.onRender();await Promise.resolve();await Promise.resolve()
 assert.equal(parent.show,true);assert.deepEqual(previewStarts,[0,1],'complete visible preview releases serial slot without unused LOD leaves')
@@ -55,7 +99,15 @@ parent.tileVisible.raise({content:{url:'fineA',trianglesLength:1}});previews.onR
 previews.onRender();assert.equal(previews.snapshot().find(r=>r.id===0).fineVisibleLastRender,false,'next rendered frame without fine clears visibility')
 parent.tileVisible.raise({content:{url:'parent',trianglesLength:1}});previews.onRender()
 parent.loadProgress.raise(1,1);assert.equal(previews.detailReady(0),true,'loaded preview preserves coarse coverage while fine waits')
-parent.tileUnload.raise({content:{url:'parent'}});assert.equal(previews.detailReady(0),false,'missing preview restores global coarse')
+// 票 61 第十五轮:同样受 900ms 保持期保护 —— 瞬时缺失不闪,持续缺失才回退。
+// 真实语义:tilesLoaded 只在"当前视图所需瓦片都处理完"时为真。瓦片被收回且没有替代
+// ⇒ tilesLoaded 回落。这里同时把两件事都模拟出来(只 raise unload 不改 tilesLoaded 是假场景)。
+parent.tileUnload.raise({content:{url:'parent'}})
+assert.equal(previews.detailReady(0),true,'missing preview within the hold window must NOT flash global coarse')
+parent.tilesLoaded=false
+ticks+=1200
+previews.onRender()
+assert.equal(previews.detailReady(0),false,'sustained missing preview restores global coarse')
 previews.detach()
 let destroys=0
 const unadded={isDestroyed:()=>destroys>0,destroy:()=>{destroys++}}
@@ -63,4 +115,4 @@ const deadScene={isDestroyed:()=>true,get primitives(){throw Error('destroyed sc
 removeSceneTileset(deadScene,unadded);removeSceneTileset(deadScene,unadded);assert.equal(destroys,1)
 let collectionRemoved=0
 removeSceneTileset({isDestroyed:()=>false,primitives:{remove(){collectionRemoved++;return true}}},{isDestroyed:()=>false,destroy(){throw Error('double destruction')}});assert.equal(collectionRemoved,1)
-console.log(JSON.stringify({passed:true,scope:'2-region/180-content admission, serial content readiness, unload/failure coarse restoration, pipeline release, late async completion'}))
+console.log(JSON.stringify({passed:true,scope:'radius/budget admission, bounded moving/stationary loading, coarse restoration, pipeline release, late async completion'}))