release: bump version to 0.30.0
This commit is contained in:
@@ -6,7 +6,8 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
import math
|
import math
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query, Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
@@ -23,6 +24,9 @@ from app.services.cable_graph import build_graph_from_data, CableGraph, haversin
|
|||||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
TERRAIN_TILE_URL_TEMPLATE = (
|
||||||
|
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============== Converter Functions ==============
|
# ============== Converter Functions ==============
|
||||||
@@ -804,6 +808,50 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/terrain/terrarium/{z}/{x}/{y}.png")
|
||||||
|
async def get_terrarium_tile(z: int, x: int, y: int):
|
||||||
|
"""Proxy Terrarium elevation tiles through the backend to avoid browser CORS issues."""
|
||||||
|
if z < 0 or x < 0 or y < 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
|
||||||
|
|
||||||
|
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=20.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
) as client:
|
||||||
|
upstream = await client.get(url)
|
||||||
|
upstream.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
detail=f"Terrain tile upstream error: {exc.response.status_code}",
|
||||||
|
) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"Terrain tile fetch failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
cache_control = upstream.headers.get("cache-control") or "public, max-age=86400"
|
||||||
|
etag = upstream.headers.get("etag")
|
||||||
|
last_modified = upstream.headers.get("last-modified")
|
||||||
|
headers = {
|
||||||
|
"Cache-Control": cache_control,
|
||||||
|
}
|
||||||
|
if etag:
|
||||||
|
headers["ETag"] = etag
|
||||||
|
if last_modified:
|
||||||
|
headers["Last-Modified"] = last_modified
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=upstream.content,
|
||||||
|
media_type=upstream.headers.get("content-type", "image/png"),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/geo/all")
|
@router.get("/geo/all")
|
||||||
async def get_all_geojson(db: AsyncSession = Depends(get_db)):
|
async def get_all_geojson(db: AsyncSession = Depends(get_db)):
|
||||||
records_by_source = await _load_current_collected_data_by_sources(
|
records_by_source = await _load_current_collected_data_by_sources(
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ This project follows the repository versioning rule:
|
|||||||
|
|
||||||
## [0.29.1] — 2026-04-20
|
## [0.29.1] — 2026-04-20
|
||||||
|
|
||||||
|
## [0.30.0] — 2026-04-21
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
- Earth 新增真实地形图层:后端代理 Terrarium DEM 瓦片(`/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png`),前端新增 `terrain.js` 负责瓦片拉取、顶点位移与按海拔着色
|
||||||
|
- 设置弹窗新增"地形"分组,支持通过滑块实时调整地形图层透明度
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 地形按钮改为异步加载,首次点击显示进度提示并在失败时自动回退
|
||||||
|
- 启动阶段改用 `applyImmediateView` 直接应用初始视角,`showStatusMessage` / `queueStatusMessage` 区分即时与队列态状态消息,加载中不再被临时状态打断
|
||||||
|
- 控制面板抽取 `applyTerrainUiState` / `getViewRotation` 收敛地形切换与视角旋转的重复 UI 同步逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.29.2] — 2026-04-21
|
## [0.29.2] — 2026-04-21
|
||||||
|
|
||||||
### ✨ Highlights
|
### ✨ Highlights
|
||||||
|
|||||||
472
docs/earth/earth-real-terrain-plan.md
Normal file
472
docs/earth/earth-real-terrain-plan.md
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
# Earth Real Terrain Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
将 Earth 页当前的“程序噪声假地形”替换成基于真实 DEM 的可用地形层,使 `地形 terrain` 开关真正显示全球海拔起伏,而不是占位效果。
|
||||||
|
|
||||||
|
当前占位实现位于:
|
||||||
|
|
||||||
|
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||||
|
|
||||||
|
具体问题:
|
||||||
|
|
||||||
|
- `createTerrain()` 直接对球体顶点应用 `simplex noise`
|
||||||
|
- 没有真实海拔数据来源
|
||||||
|
- 没有分辨率分层
|
||||||
|
- 没有和当前相机/视角配套的性能控制
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
本计划必须贴合当前 Earth 架构,而不是引入一套全新的地形引擎:
|
||||||
|
|
||||||
|
- 地球主体仍然是一个 Three.js sphere
|
||||||
|
- 海缆、登陆点、卫星、BGP 都已经建立在当前球体坐标系之上
|
||||||
|
- 不能为了地形把整页改成 Cesium/MapLibre Globe 之类的全栈替换
|
||||||
|
- 第一阶段优先做“真实可用”,不是一步到位做摄影测量级地形
|
||||||
|
|
||||||
|
## Recommended Data Source
|
||||||
|
|
||||||
|
### Primary recommendation
|
||||||
|
|
||||||
|
使用公开的 Terrarium 编码高程瓦片作为浏览器端高度来源,第一阶段优先接入:
|
||||||
|
|
||||||
|
- Mapzen/AWS `Terrarium` elevation tiles
|
||||||
|
参考:[Mapzen terrain tile format / Terrarium](https://www.mapzen.com/blog/terrain-tile-service/)
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 已经是全球瓦片化高程
|
||||||
|
- 浏览器端按 tile 请求,最适合当前 Earth 这种在线 globe
|
||||||
|
- 编码简单稳定:
|
||||||
|
- `heightMeters = (R * 256 + G + B / 256) - 32768`
|
||||||
|
- 不需要我们先离线拼整球 DEM
|
||||||
|
|
||||||
|
### Data quality upgrade path
|
||||||
|
|
||||||
|
如果后面第一阶段效果确认可用,再逐步升级到底层源:
|
||||||
|
|
||||||
|
- Copernicus DEM GLO-30
|
||||||
|
参考:[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
|
||||||
|
- 或用 Copernicus / SRTM / ASTER 等离线切成我们自己的 terrain tiles
|
||||||
|
|
||||||
|
这条升级路径适合第二阶段,不建议一开始就直接自建全球瓦片服务。
|
||||||
|
|
||||||
|
## Why Not Replace the Engine
|
||||||
|
|
||||||
|
不建议为了地形直接切到 Cesium terrain / quantized mesh 引擎,原因:
|
||||||
|
|
||||||
|
- 现有 Earth 业务对象都依附当前球面坐标
|
||||||
|
- 切引擎会同时波及:
|
||||||
|
- 海缆绘制
|
||||||
|
- 卫星/轨迹
|
||||||
|
- BGP 标记
|
||||||
|
- HUD 与交互
|
||||||
|
- 这是“重做一页”,不是“给地形层接真实数据”
|
||||||
|
|
||||||
|
所以推荐路线是:
|
||||||
|
|
||||||
|
- 保持当前 sphere globe
|
||||||
|
- 为 sphere 增加真实高度位移层
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
分三期推进。
|
||||||
|
|
||||||
|
### Phase 1 — Global Heightmap Terrain Overlay
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 地形层切换后显示真实海拔起伏
|
||||||
|
- 全球范围可用
|
||||||
|
- 性能可控
|
||||||
|
|
||||||
|
做法:
|
||||||
|
|
||||||
|
1. 新增 terrain 数据模块
|
||||||
|
|
||||||
|
建议文件:
|
||||||
|
|
||||||
|
- `frontend/public/earth/js/terrain.js`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 选择 DEM zoom level
|
||||||
|
- 请求 Terrarium tiles
|
||||||
|
- 解码 tile 高程
|
||||||
|
- 将高程重采样到当前地形球体网格
|
||||||
|
|
||||||
|
2. 替换 `createTerrain()`
|
||||||
|
|
||||||
|
当前:
|
||||||
|
|
||||||
|
- 在 [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) 中同步生成噪声地形
|
||||||
|
|
||||||
|
调整后:
|
||||||
|
|
||||||
|
- `createTerrain()` 只负责创建 terrain mesh 骨架
|
||||||
|
- 真正的顶点位移由 terrain 模块异步注入
|
||||||
|
|
||||||
|
3. 第一阶段采用“整球低分辨率位移”
|
||||||
|
|
||||||
|
不要一上来做动态 patch stitching。第一阶段更稳的办法是:
|
||||||
|
|
||||||
|
- 保留一张全球 terrain sphere
|
||||||
|
- 使用较低分辨率几何
|
||||||
|
- 例如 `SphereGeometry(radius, 192, 192)` 或 `256/256`
|
||||||
|
- 运行时按一个固定地形 zoom(如 `z=4` 或 `z=5`)抓取覆盖全球的 Terrarium tiles
|
||||||
|
- 将 tile 解码后重投影到经纬度采样网格
|
||||||
|
- 将每个球面顶点按真实高度抬升
|
||||||
|
|
||||||
|
这样第一阶段就能做到:
|
||||||
|
|
||||||
|
- 有真实地形
|
||||||
|
- 不需要复杂的局部 LOD
|
||||||
|
- 不会让现有球体对象体系爆炸
|
||||||
|
|
||||||
|
### Phase 2 — View-Aware Refinement
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 正面可见区域更精细
|
||||||
|
- 背面与远处维持低成本
|
||||||
|
|
||||||
|
做法:
|
||||||
|
|
||||||
|
- 引入“基础全球地形 + 当前视角高分局部补丁”
|
||||||
|
- 正面区域额外抓更高 zoom 的高程 tile
|
||||||
|
- 只替换局部顶点位移或局部 overlay mesh
|
||||||
|
|
||||||
|
这一阶段适合在第一阶段稳定后做。
|
||||||
|
|
||||||
|
### Phase 3 — Normals / Shading / Terrain UX
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 地形不仅有起伏,还更好看、更可读
|
||||||
|
|
||||||
|
包括:
|
||||||
|
|
||||||
|
- 根据高度生成更合理的 normals
|
||||||
|
- 调整 terrain material,使山脉/高原更易读
|
||||||
|
- 可选加入:
|
||||||
|
- hillshade
|
||||||
|
- contour lines
|
||||||
|
- snowline / bathymetry tint
|
||||||
|
|
||||||
|
## Calibration Overlay Before More Terrain Tuning
|
||||||
|
|
||||||
|
在当前项目里,terrain 看起来“不像真地形”,不一定只是 DEM 或 exaggeration 不够,也可能是因为缺少稳定参照物。
|
||||||
|
|
||||||
|
没有清晰的海岸线、国界线和地表分层时,人眼很难判断:
|
||||||
|
|
||||||
|
- 山脉是不是在应该高的地方高
|
||||||
|
- terrain 是否真的贴在正确的大陆位置上
|
||||||
|
- 地球纹理、本初子午线、terrain 采样之间是否存在偏移
|
||||||
|
|
||||||
|
这里要明确区分两件事:
|
||||||
|
|
||||||
|
- 国界线不会修好错误的 terrain
|
||||||
|
- 但海岸线 / 国界线会让我们更容易判断 terrain 有没有贴准
|
||||||
|
|
||||||
|
所以在继续盲调 terrain 参数之前,建议先插入一个“校准参照层”阶段。
|
||||||
|
|
||||||
|
### Recommended order for the calibration layer
|
||||||
|
|
||||||
|
1. 海岸线
|
||||||
|
2. 国界线
|
||||||
|
3. 再继续调 terrain
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 海岸线比国界线更基础,也更接近真实地表边界
|
||||||
|
- 判断 terrain 是否贴准,最重要的是大陆边缘和山脉/海岸关系
|
||||||
|
- 国界线更多是政治边界,只能作为辅助参照
|
||||||
|
|
||||||
|
如果只加国界线,不加海岸线,效果仍然可能会怪,因为:
|
||||||
|
|
||||||
|
- 很多国界线本来就是人为直线
|
||||||
|
- 它们并不总是跟真实地形走
|
||||||
|
|
||||||
|
### Suggested layer order during debugging
|
||||||
|
|
||||||
|
建议调试期临时把地球层次明确成:
|
||||||
|
|
||||||
|
1. base earth texture
|
||||||
|
2. coastline / borders overlay
|
||||||
|
3. terrain relief
|
||||||
|
4. cables / landing points / bgp / satellites
|
||||||
|
|
||||||
|
这样会比现在更容易判断:
|
||||||
|
|
||||||
|
- 山脉是否位于正确区域
|
||||||
|
- terrain 是否和地表对齐
|
||||||
|
- 国界/海岸是否漂移
|
||||||
|
|
||||||
|
### Suggested data source for the calibration overlay
|
||||||
|
|
||||||
|
优先用 `Natural Earth` 的轻量全球矢量数据:
|
||||||
|
|
||||||
|
- 海岸线(coastline)
|
||||||
|
- Admin 0 国界线(country borders)
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 全球一致
|
||||||
|
- 轻量
|
||||||
|
- 很适合当前 Three.js globe 做 overlay
|
||||||
|
|
||||||
|
### Recommended execution path
|
||||||
|
|
||||||
|
#### Phase A — Add reference overlays
|
||||||
|
|
||||||
|
先加两层可开关的参考线:
|
||||||
|
|
||||||
|
- 海岸线
|
||||||
|
- 国界线
|
||||||
|
|
||||||
|
这两层的目标不是最终美术表现,而是调试 / 校准。
|
||||||
|
|
||||||
|
#### Phase B — Recalibrate terrain against coastline
|
||||||
|
|
||||||
|
有了海岸线以后,再重新看 terrain:
|
||||||
|
|
||||||
|
- terrain 是否和大陆边缘错位
|
||||||
|
- 地球纹理、本初子午线、terrain 采样之间是否有固定偏移
|
||||||
|
|
||||||
|
#### Phase C — Decide whether to keep the current terrain path
|
||||||
|
|
||||||
|
这时再决定后面的路线:
|
||||||
|
|
||||||
|
- 如果发现真实高程整体是对的,只是缺少 shading / readability
|
||||||
|
继续保留当前 DEM + terrain overlay 路线
|
||||||
|
- 如果发现整球采样投影、本初子午线或 overlay 关系本身就很别扭
|
||||||
|
再考虑重做 terrain pipeline
|
||||||
|
|
||||||
|
### Practical recommendation
|
||||||
|
|
||||||
|
当前阶段不建议“从头开始重做 terrain”。
|
||||||
|
|
||||||
|
更稳的策略是:
|
||||||
|
|
||||||
|
- 暂停继续盲调 terrain 参数
|
||||||
|
- 先补海岸线 / 国界线作为校准参照层
|
||||||
|
- 再基于参照层判断 terrain 是“参数没调好”,还是“整条实现路径有偏移”
|
||||||
|
|
||||||
|
## Recommended Geometry Model
|
||||||
|
|
||||||
|
### First usable model
|
||||||
|
|
||||||
|
保留一层独立 terrain sphere:
|
||||||
|
|
||||||
|
- base earth sphere:贴纹理、昼夜、海洋
|
||||||
|
- terrain sphere:略高于地球半径,真实高程位移
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- `terrainBaseRadius = CONFIG.earthRadius + 0.2`
|
||||||
|
- 高度缩放使用真实米制换算,再乘一个可调 exaggeration
|
||||||
|
|
||||||
|
示例关系:
|
||||||
|
|
||||||
|
- `heightWorld = (elevationMeters / 6371000) * CONFIG.earthRadius * exaggeration`
|
||||||
|
|
||||||
|
建议第一阶段 `exaggeration = 1.3 ~ 1.8`
|
||||||
|
|
||||||
|
因为完全真实比例在全球球体上会太平,看不出来。
|
||||||
|
|
||||||
|
## Tile Decoding Plan
|
||||||
|
|
||||||
|
### Terrarium decode
|
||||||
|
|
||||||
|
对于每个高程 tile 像素:
|
||||||
|
|
||||||
|
```text
|
||||||
|
heightMeters = (R * 256 + G + B / 256) - 32768
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sampling path
|
||||||
|
|
||||||
|
对于 terrain mesh 上每个顶点:
|
||||||
|
|
||||||
|
1. 将顶点方向转成经纬度
|
||||||
|
2. 将经纬度映射到 Web Mercator tile 坐标
|
||||||
|
3. 找到对应的 tile 和像素
|
||||||
|
4. 解码高程
|
||||||
|
5. 将顶点沿法线方向抬升
|
||||||
|
|
||||||
|
### Needed helpers
|
||||||
|
|
||||||
|
建议新增:
|
||||||
|
|
||||||
|
- `latLonToTileXY(lat, lon, z)`
|
||||||
|
- `tilePixelFromLatLon(lat, lon, z, tileSize)`
|
||||||
|
- `decodeTerrariumHeight(r, g, b)`
|
||||||
|
|
||||||
|
## Caching Strategy
|
||||||
|
|
||||||
|
为了不让地形开关每次重开都重新抓全量 tile:
|
||||||
|
|
||||||
|
- terrain tile 按 `z/x/y` 存到内存缓存
|
||||||
|
- terrain mesh 结果也缓存一份
|
||||||
|
- 当用户关闭/开启 terrain:
|
||||||
|
- 直接复用已有位移结果
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- `Map<string, Float32Array | ImageBitmap>`
|
||||||
|
|
||||||
|
## Material Strategy
|
||||||
|
|
||||||
|
第一阶段不要复杂化。
|
||||||
|
|
||||||
|
建议 terrain material:
|
||||||
|
|
||||||
|
- 半透明低饱和地形色
|
||||||
|
- 比 base earth 稍亮或稍偏冷
|
||||||
|
- 保留当前 HUD 风格下的可读性
|
||||||
|
|
||||||
|
第一阶段不需要:
|
||||||
|
|
||||||
|
- 真实土地覆被纹理
|
||||||
|
- 独立卫星影像贴 terrain
|
||||||
|
|
||||||
|
因为那会和现有地球纹理、云层、昼夜 shader 打架。
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### Files to change
|
||||||
|
|
||||||
|
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||||
|
- 重写 `createTerrain()`
|
||||||
|
- 删除 simplex noise 占位逻辑
|
||||||
|
- [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||||
|
- 初始化 terrain 数据加载
|
||||||
|
- 控制 terrain readiness / loading message
|
||||||
|
- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||||
|
- `toggleTerrain` 逻辑保持,但应能区分:
|
||||||
|
- mesh 已就绪
|
||||||
|
- 正在加载
|
||||||
|
- [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||||
|
- 新增 `TERRAIN_CONFIG`
|
||||||
|
- 新文件:
|
||||||
|
- `frontend/public/earth/js/terrain.js`
|
||||||
|
|
||||||
|
### Suggested new config
|
||||||
|
|
||||||
|
建议新增:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const TERRAIN_CONFIG = {
|
||||||
|
enabled: true,
|
||||||
|
tileSize: 256,
|
||||||
|
baseZoom: 4,
|
||||||
|
baseRadiusOffset: 0.2,
|
||||||
|
exaggeration: 1.5,
|
||||||
|
opacity: 0.55,
|
||||||
|
color: 0x6c876f,
|
||||||
|
maxConcurrentRequests: 8,
|
||||||
|
cacheEnabled: true,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loading UX
|
||||||
|
|
||||||
|
地形第一次开启时,不能像现在一样瞬时切换。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 如果地形数据尚未准备:
|
||||||
|
- 顶部状态条显示:`正在加载真实地形数据...`
|
||||||
|
- 完成后:
|
||||||
|
- `真实地形已就绪`
|
||||||
|
|
||||||
|
如果加载失败:
|
||||||
|
|
||||||
|
- 保留 base earth
|
||||||
|
- 显示轻量错误提示
|
||||||
|
- 不要让 terrain 开关卡死在“开”状态
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
### 1. Global tile count too high
|
||||||
|
|
||||||
|
即使 `z=5` 全球 tile 数也不少。
|
||||||
|
|
||||||
|
缓解:
|
||||||
|
|
||||||
|
- 第一阶段限定低 zoom
|
||||||
|
- 并发上限
|
||||||
|
- 缓存
|
||||||
|
|
||||||
|
### 2. Mesh resolution too low
|
||||||
|
|
||||||
|
如果球面分段太低,山脉会被抹平。
|
||||||
|
|
||||||
|
缓解:
|
||||||
|
|
||||||
|
- 第一阶段先选一个中等分辨率
|
||||||
|
- 用 exaggeration 保证可见性
|
||||||
|
|
||||||
|
### 3. Existing overlays may z-fight with terrain
|
||||||
|
|
||||||
|
海缆、登陆点、BGP、卫星相关对象都假设地球半径固定。
|
||||||
|
|
||||||
|
缓解:
|
||||||
|
|
||||||
|
- terrain sphere 单独作为 overlay
|
||||||
|
- overlay 保持略低或略高的固定 offset
|
||||||
|
- 必要时局部调整 landing point / cable altitude offset
|
||||||
|
|
||||||
|
### 4. Mercator sampling distortion near poles
|
||||||
|
|
||||||
|
Web Mercator 在高纬会有失真。
|
||||||
|
|
||||||
|
缓解:
|
||||||
|
|
||||||
|
- 第一阶段接受
|
||||||
|
- 后续若需要更严格极区质量,再上 geodetic reprojection pipeline
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
第一阶段完成后,应满足:
|
||||||
|
|
||||||
|
1. `地形 terrain` 开关开启时,地表起伏明显不再是随机噪声
|
||||||
|
2. 喜马拉雅、安第斯、落基山、东非高原等全球大尺度地形可辨认
|
||||||
|
3. 关闭/重新开启 terrain 不重复全量请求
|
||||||
|
4. 不破坏:
|
||||||
|
- 海缆
|
||||||
|
- 卫星
|
||||||
|
- BGP
|
||||||
|
- 地球昼夜
|
||||||
|
- 天球层
|
||||||
|
|
||||||
|
## Suggested Execution Order
|
||||||
|
|
||||||
|
1. 引入 `TERRAIN_CONFIG`
|
||||||
|
2. 新建 `terrain.js`
|
||||||
|
3. 实现 Terrarium tile 请求与 decode
|
||||||
|
4. 用低 zoom 全球 tile 构建真实 terrain sphere
|
||||||
|
5. 接管 `toggleTerrain()`
|
||||||
|
6. 调整 terrain material 和高度 exaggeration
|
||||||
|
7. 做缓存
|
||||||
|
8. 再考虑第二阶段局部高分 refinement
|
||||||
|
|
||||||
|
## Source References
|
||||||
|
|
||||||
|
- Mapzen Terrarium / AWS terrain tiles
|
||||||
|
[Mapzen Terrain Tile Service](https://www.mapzen.com/blog/terrain-tile-service/)
|
||||||
|
- Terrarium tile experiments / format background
|
||||||
|
[mapzen/terrarium](https://github.com/mapzen/terrarium)
|
||||||
|
- Copernicus DEM overview
|
||||||
|
[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
|
||||||
|
|
||||||
|
## Recommendation Summary
|
||||||
|
|
||||||
|
如果现在就要开始做,我建议直接按这条路线开工:
|
||||||
|
|
||||||
|
- 第一阶段接入 Terrarium 全球高程 tile
|
||||||
|
- 替换掉当前 simplex 假地形
|
||||||
|
- 先做一层真实可见的全球 terrain overlay
|
||||||
|
- 等第一阶段稳定,再做视角高分 refinement
|
||||||
|
|
||||||
|
这是对当前项目风险最低、最贴合现有 Earth 架构的一条路。
|
||||||
@@ -16,12 +16,13 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.29.2`
|
- `dev` 当前开发分支历史推导到:`0.30.0`
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
| Version | Type | Branch | Commit | Summary |
|
| Version | Type | Branch | Commit | Summary |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层(Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 |
|
||||||
| `0.29.2` | bugfix | `dev` | `pending` | 修正 Earth 设置弹窗展开表现与系统入口,继续统一液态玻璃 HUD,并校正太阳受光方向 |
|
| `0.29.2` | bugfix | `dev` | `pending` | 修正 Earth 设置弹窗展开表现与系统入口,继续统一液态玻璃 HUD,并校正太阳受光方向 |
|
||||||
| `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示,brand panel 去框并收敛昼夜与选中态可读性 |
|
| `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示,brand panel 去框并收敛昼夜与选中态可读性 |
|
||||||
| `0.29.0` | feature | `dev` | `pending` | Earth 新增天球背景与太阳/月亮位置层,强化昼夜分隔并收口卫星图例与图层面板交互 |
|
| `0.29.0` | feature | `dev` | `pending` | Earth 新增天球背景与太阳/月亮位置层,强化昼夜分隔并收口卫星图例与图层面板交互 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.29.2",
|
"version": "0.30.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -311,6 +311,7 @@
|
|||||||
0 0 18px rgba(145, 186, 255, 0.06);
|
0 0 18px rgba(145, 186, 255, 0.06);
|
||||||
font-size: calc(0.84rem * var(--hud-scale));
|
font-size: calc(0.84rem * var(--hud-scale));
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
@@ -334,8 +335,10 @@
|
|||||||
.earth-status-indicator {
|
.earth-status-indicator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: calc(5px * var(--hud-scale));
|
gap: calc(5px * var(--hud-scale));
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.earth-status-dot {
|
.earth-status-dot {
|
||||||
@@ -350,6 +353,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.earth-status-text {
|
.earth-status-text {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -608,10 +613,73 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.earth-settings-item--stacked {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.earth-settings-item--stacked:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.earth-settings-link {
|
.earth-settings-link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.earth-settings-slider-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.earth-settings-slider {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
appearance: none;
|
||||||
|
background: linear-gradient(90deg, rgba(132, 164, 204, 0.32), rgba(94, 130, 172, 0.5));
|
||||||
|
border-radius: 999px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.earth-settings-slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.22) 55%, transparent 70%),
|
||||||
|
linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
|
||||||
|
border: 1px solid rgba(222, 236, 252, 0.4);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||||
|
0 6px 16px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.earth-settings-slider::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
|
||||||
|
border: 1px solid rgba(222, 236, 252, 0.4);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||||
|
0 6px 16px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.earth-settings-slider-value {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 46px;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--hud-text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.earth-settings-copy {
|
.earth-settings-copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -469,6 +469,30 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="earth-settings-section">
|
||||||
|
<div class="earth-settings-section-title">地形</div>
|
||||||
|
<div class="earth-settings-list">
|
||||||
|
<div class="earth-settings-item earth-settings-item--stacked">
|
||||||
|
<div class="earth-settings-copy">
|
||||||
|
<span class="earth-settings-item-title">地形透明度</span>
|
||||||
|
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-settings-slider-row">
|
||||||
|
<input
|
||||||
|
id="terrain-opacity-slider"
|
||||||
|
class="earth-settings-slider"
|
||||||
|
type="range"
|
||||||
|
min="0.05"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value="0.62"
|
||||||
|
aria-label="调整地形透明度"
|
||||||
|
>
|
||||||
|
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="earth-settings-section">
|
<section class="earth-settings-section">
|
||||||
<div class="earth-settings-section-title">系统</div>
|
<div class="earth-settings-section-title">系统</div>
|
||||||
<div class="earth-settings-list">
|
<div class="earth-settings-list">
|
||||||
|
|||||||
@@ -245,9 +245,12 @@ export function clearCableData(earthObj = null) {
|
|||||||
clearLandingPoints(earthObj);
|
clearLandingPoints(earthObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadGeoJSONFromPath(scene, earthObj) {
|
export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||||
|
const { silent = false } = options;
|
||||||
console.log("正在加载电缆数据...");
|
console.log("正在加载电缆数据...");
|
||||||
showStatusMessage("正在加载电缆数据...", "warning");
|
if (!silent) {
|
||||||
|
showStatusMessage("正在加载电缆数据...", "warning");
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(PATHS.cablesApi);
|
const response = await fetch(PATHS.cablesApi);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -344,11 +347,14 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
|
|||||||
textureQuality: "8K 卫星图",
|
textureQuality: "8K 卫星图",
|
||||||
});
|
});
|
||||||
|
|
||||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
if (!silent) {
|
||||||
|
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||||
|
}
|
||||||
return cableLines.length;
|
return cableLines.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLandingPoints(scene, earthObj) {
|
export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||||
|
const { silent = false } = options;
|
||||||
console.log("正在加载登陆点数据...");
|
console.log("正在加载登陆点数据...");
|
||||||
|
|
||||||
const response = await fetch(PATHS.landingPointsApi);
|
const response = await fetch(PATHS.landingPointsApi);
|
||||||
@@ -434,7 +440,9 @@ export async function loadLandingPoints(scene, earthObj) {
|
|||||||
landingPointCountEl.textContent = validCount + "个";
|
landingPointCountEl.textContent = validCount + "个";
|
||||||
}
|
}
|
||||||
|
|
||||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
if (!silent) {
|
||||||
|
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||||
|
}
|
||||||
return validCount;
|
return validCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,25 @@ export const CELESTIAL_CONFIG = {
|
|||||||
backLightColor: 0x2b4c78,
|
backLightColor: 0x2b4c78,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TERRAIN_CONFIG = {
|
||||||
|
enabled: true,
|
||||||
|
tileSize: 256,
|
||||||
|
baseZoom: 4,
|
||||||
|
geometryWidthSegments: 320,
|
||||||
|
geometryHeightSegments: 320,
|
||||||
|
baseRadiusOffset: 0.04,
|
||||||
|
exaggeration: 34,
|
||||||
|
landRevealFadeMeters: 220,
|
||||||
|
maxConcurrentRequests: 10,
|
||||||
|
opacity: 0.62,
|
||||||
|
color: 0x7f9d7f,
|
||||||
|
emissive: 0x061008,
|
||||||
|
specular: 0x233126,
|
||||||
|
shininess: 10,
|
||||||
|
urlTemplate:
|
||||||
|
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
|
||||||
|
};
|
||||||
|
|
||||||
export const PATHS = {
|
export const PATHS = {
|
||||||
cablesApi: '/api/v1/visualization/geo/cables',
|
cablesApi: '/api/v1/visualization/geo/cables',
|
||||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||||
|
|||||||
114
frontend/public/earth/js/controls.js
vendored
114
frontend/public/earth/js/controls.js
vendored
@@ -4,6 +4,12 @@ import * as THREE from "three";
|
|||||||
import { CONFIG, EARTH_CONFIG } from "./constants.js";
|
import { CONFIG, EARTH_CONFIG } from "./constants.js";
|
||||||
import { updateZoomDisplay, showStatusMessage } from "./ui.js";
|
import { updateZoomDisplay, showStatusMessage } from "./ui.js";
|
||||||
import { toggleTerrain } from "./earth.js";
|
import { toggleTerrain } from "./earth.js";
|
||||||
|
import {
|
||||||
|
ensureTerrainReady,
|
||||||
|
isTerrainReady,
|
||||||
|
getTerrainOpacity,
|
||||||
|
setTerrainOpacity,
|
||||||
|
} from "./terrain.js";
|
||||||
import {
|
import {
|
||||||
reloadData,
|
reloadData,
|
||||||
clearLockedObject,
|
clearLockedObject,
|
||||||
@@ -58,6 +64,44 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
|
|||||||
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
||||||
let settingsModalTimer = null;
|
let settingsModalTimer = null;
|
||||||
let settingsSheetAnimation = null;
|
let settingsSheetAnimation = null;
|
||||||
|
let terrainToggleToken = 0;
|
||||||
|
|
||||||
|
function getViewRotation(targetLat, targetRotLon) {
|
||||||
|
const latRot = (targetLat * Math.PI) / 180;
|
||||||
|
return {
|
||||||
|
x: EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient,
|
||||||
|
y: -((targetRotLon * Math.PI) / 180),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTerrainUiState(button, enabled) {
|
||||||
|
showTerrain = enabled;
|
||||||
|
toggleTerrain(enabled);
|
||||||
|
updateLayerButtonState(button, enabled);
|
||||||
|
setButtonTooltip(button, enabled ? "隐藏地形" : "显示地形");
|
||||||
|
const terrainStatus = document.getElementById("terrain-status");
|
||||||
|
if (terrainStatus) terrainStatus.textContent = enabled ? "开启" : "关闭";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyImmediateView(targetEarthObj, camera, options = {}) {
|
||||||
|
if (!targetEarthObj) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
lat = EARTH_CONFIG.chinaLat,
|
||||||
|
rotLon = EARTH_CONFIG.chinaRotLon,
|
||||||
|
zoom = 1.0,
|
||||||
|
} = options;
|
||||||
|
const nextRotation = getViewRotation(lat, rotLon);
|
||||||
|
|
||||||
|
targetEarthObj.rotation.x = nextRotation.x;
|
||||||
|
targetEarthObj.rotation.y = nextRotation.y;
|
||||||
|
zoomLevel = zoom;
|
||||||
|
|
||||||
|
if (camera) {
|
||||||
|
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||||
|
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cancelSettingsSheetAnimation() {
|
function cancelSettingsSheetAnimation() {
|
||||||
if (settingsSheetAnimation) {
|
if (settingsSheetAnimation) {
|
||||||
@@ -316,6 +360,32 @@ function setupSettingsControls() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
|
||||||
|
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
|
||||||
|
const syncTerrainOpacityUi = (nextOpacity) => {
|
||||||
|
const safeOpacity = Math.round(nextOpacity * 100);
|
||||||
|
if (terrainOpacitySlider instanceof HTMLInputElement) {
|
||||||
|
terrainOpacitySlider.value = nextOpacity.toFixed(2);
|
||||||
|
}
|
||||||
|
if (terrainOpacityValue) {
|
||||||
|
terrainOpacityValue.textContent = `${safeOpacity}%`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncTerrainOpacityUi(getTerrainOpacity());
|
||||||
|
|
||||||
|
if (terrainOpacitySlider instanceof HTMLInputElement) {
|
||||||
|
bindListener(terrainOpacitySlider, "input", (event) => {
|
||||||
|
const target = event.currentTarget;
|
||||||
|
if (!(target instanceof HTMLInputElement)) return;
|
||||||
|
const nextOpacity = Number.parseFloat(target.value);
|
||||||
|
const appliedOpacity = setTerrainOpacity(
|
||||||
|
Number.isFinite(nextOpacity) ? nextOpacity : getTerrainOpacity(),
|
||||||
|
);
|
||||||
|
syncTerrainOpacityUi(appliedOpacity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
syncAllHudPanelToggles();
|
syncAllHudPanelToggles();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,10 +760,7 @@ export function resetView(camera) {
|
|||||||
if (!earthObj) return;
|
if (!earthObj) return;
|
||||||
|
|
||||||
function animateToView(targetLat, targetLon, targetRotLon) {
|
function animateToView(targetLat, targetLon, targetRotLon) {
|
||||||
const latRot = (targetLat * Math.PI) / 180;
|
const targetRotation = getViewRotation(targetLat, targetRotLon);
|
||||||
const targetRotX =
|
|
||||||
EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
|
|
||||||
const targetRotY = -((targetRotLon * Math.PI) / 180);
|
|
||||||
|
|
||||||
const startRotX = earthObj.rotation.x;
|
const startRotX = earthObj.rotation.x;
|
||||||
const startRotY = earthObj.rotation.y;
|
const startRotY = earthObj.rotation.y;
|
||||||
@@ -706,8 +773,10 @@ export function resetView(camera) {
|
|||||||
800,
|
800,
|
||||||
(progress) => {
|
(progress) => {
|
||||||
const ease = 1 - Math.pow(1 - progress, 3);
|
const ease = 1 - Math.pow(1 - progress, 3);
|
||||||
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
|
earthObj.rotation.x =
|
||||||
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
|
startRotX + (targetRotation.x - startRotX) * ease;
|
||||||
|
earthObj.rotation.y =
|
||||||
|
startRotY + (targetRotation.y - startRotY) * ease;
|
||||||
|
|
||||||
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
|
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
|
||||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||||
@@ -878,15 +947,30 @@ function setupTerrainControls() {
|
|||||||
showStatusMessage("搜索功能待开发", "info");
|
showStatusMessage("搜索功能待开发", "info");
|
||||||
});
|
});
|
||||||
|
|
||||||
bindListener(terrainBtn, "click", function () {
|
bindListener(terrainBtn, "click", async function () {
|
||||||
showTerrain = !showTerrain;
|
const nextShowTerrain = !showTerrain;
|
||||||
toggleTerrain(showTerrain);
|
const toggleToken = ++terrainToggleToken;
|
||||||
updateLayerButtonState(this, showTerrain);
|
|
||||||
setButtonTooltip(this, showTerrain ? "隐藏地形" : "显示地形");
|
if (!nextShowTerrain) {
|
||||||
const terrainStatus = document.getElementById("terrain-status");
|
applyTerrainUiState(this, false);
|
||||||
if (terrainStatus)
|
showStatusMessage("地形已隐藏", "info");
|
||||||
terrainStatus.textContent = showTerrain ? "开启" : "关闭";
|
return;
|
||||||
showStatusMessage(showTerrain ? "地形已显示" : "地形已隐藏", "info");
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isTerrainReady()) {
|
||||||
|
showStatusMessage("正在加载真实地形数据...", "info");
|
||||||
|
await ensureTerrainReady();
|
||||||
|
}
|
||||||
|
if (toggleToken !== terrainToggleToken) return;
|
||||||
|
|
||||||
|
applyTerrainUiState(this, true);
|
||||||
|
showStatusMessage("真实地形已显示", "success");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载真实地形失败:", error);
|
||||||
|
applyTerrainUiState(this, false);
|
||||||
|
showStatusMessage("真实地形暂时不可用", "error");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
bindListener(satellitesBtn, "click", async function () {
|
bindListener(satellitesBtn, "click", async function () {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// earth.js - 3D Earth creation module
|
// earth.js - 3D Earth creation module
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG } from './constants.js';
|
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
|
||||||
import { latLonToVector3 } from './utils.js';
|
import { latLonToVector3 } from './utils.js';
|
||||||
|
|
||||||
export let earth = null;
|
export let earth = null;
|
||||||
@@ -212,34 +212,34 @@ export function createClouds(scene, earthObj) {
|
|||||||
return clouds;
|
return clouds;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTerrain(scene, earthObj, simplex) {
|
export function createTerrain(earthObj) {
|
||||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
|
const geometry = new THREE.SphereGeometry(
|
||||||
const positionAttribute = geometry.getAttribute('position');
|
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
|
||||||
|
TERRAIN_CONFIG.geometryWidthSegments,
|
||||||
for (let i = 0; i < positionAttribute.count; i++) {
|
TERRAIN_CONFIG.geometryHeightSegments,
|
||||||
const x = positionAttribute.getX(i);
|
);
|
||||||
const y = positionAttribute.getY(i);
|
|
||||||
const z = positionAttribute.getZ(i);
|
|
||||||
|
|
||||||
const noise = simplex(x / 20, y / 20, z / 20);
|
|
||||||
const height = 1 + noise * 0.02;
|
|
||||||
|
|
||||||
positionAttribute.setXYZ(i, x * height, y * height, z * height);
|
|
||||||
}
|
|
||||||
|
|
||||||
geometry.computeVertexNormals();
|
|
||||||
|
|
||||||
const material = new THREE.MeshPhongMaterial({
|
const material = new THREE.MeshPhongMaterial({
|
||||||
color: 0x00aa00,
|
color: TERRAIN_CONFIG.color,
|
||||||
flatShading: true,
|
emissive: TERRAIN_CONFIG.emissive,
|
||||||
|
specular: TERRAIN_CONFIG.specular,
|
||||||
|
shininess: TERRAIN_CONFIG.shininess,
|
||||||
|
vertexColors: true,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.7
|
opacity: TERRAIN_CONFIG.opacity,
|
||||||
|
flatShading: false,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: true,
|
||||||
|
polygonOffset: true,
|
||||||
|
polygonOffsetFactor: -1,
|
||||||
|
polygonOffsetUnits: -1,
|
||||||
});
|
});
|
||||||
|
|
||||||
terrain = new THREE.Mesh(geometry, material);
|
terrain = new THREE.Mesh(geometry, material);
|
||||||
|
terrain.name = "earth-real-terrain";
|
||||||
terrain.visible = false;
|
terrain.visible = false;
|
||||||
|
terrain.renderOrder = 0.5;
|
||||||
earthObj.add(terrain);
|
earthObj.add(terrain);
|
||||||
|
|
||||||
return terrain;
|
return terrain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { createNoise3D } from "simplex-noise";
|
|
||||||
|
|
||||||
import { CONFIG, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js";
|
import { CONFIG, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js";
|
||||||
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
|
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
|
||||||
import {
|
import {
|
||||||
showStatusMessage,
|
showStatusMessage,
|
||||||
|
queueStatusMessage,
|
||||||
updateCoordinatesDisplay,
|
updateCoordinatesDisplay,
|
||||||
updateZoomDisplay,
|
updateZoomDisplay,
|
||||||
updateEarthStats,
|
updateEarthStats,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
clearEarthTexture,
|
clearEarthTexture,
|
||||||
setEarthSunDirection,
|
setEarthSunDirection,
|
||||||
} from "./earth.js";
|
} from "./earth.js";
|
||||||
|
import { registerTerrainMesh, clearTerrainData } from "./terrain.js";
|
||||||
import {
|
import {
|
||||||
initCelestialLayer,
|
initCelestialLayer,
|
||||||
updateCelestialLayer,
|
updateCelestialLayer,
|
||||||
@@ -118,7 +119,7 @@ import {
|
|||||||
getAutoRotate,
|
getAutoRotate,
|
||||||
getShowTerrain,
|
getShowTerrain,
|
||||||
setAutoRotate,
|
setAutoRotate,
|
||||||
resetView,
|
applyImmediateView,
|
||||||
getZoomLevel,
|
getZoomLevel,
|
||||||
teardownControls,
|
teardownControls,
|
||||||
updateLayerButtonState,
|
updateLayerButtonState,
|
||||||
@@ -142,7 +143,6 @@ export let scene;
|
|||||||
export let camera;
|
export let camera;
|
||||||
export let renderer;
|
export let renderer;
|
||||||
|
|
||||||
let simplex;
|
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
let previousMousePosition = { x: 0, y: 0 };
|
let previousMousePosition = { x: 0, y: 0 };
|
||||||
let targetRotation = { x: 0, y: 0 };
|
let targetRotation = { x: 0, y: 0 };
|
||||||
@@ -807,8 +807,10 @@ async function ensureCablesEnabled() {
|
|||||||
|
|
||||||
clearCableData(earth);
|
clearCableData(earth);
|
||||||
// Load landing points first so they appear before cable lines
|
// Load landing points first so they appear before cable lines
|
||||||
await loadLandingPoints(scene, earth);
|
await loadLandingPoints(scene, earth, { silent: true });
|
||||||
const cableCount = await loadGeoJSONFromPath(scene, earth);
|
const cableCount = await loadGeoJSONFromPath(scene, earth, {
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (requestToken !== cableToggleToken || !cablesEnabled || destroyed) {
|
if (requestToken !== cableToggleToken || !cablesEnabled || destroyed) {
|
||||||
clearCableData(earth);
|
clearCableData(earth);
|
||||||
@@ -913,7 +915,6 @@ export function init() {
|
|||||||
|
|
||||||
destroyed = false;
|
destroyed = false;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
simplex = createNoise3D();
|
|
||||||
updateHudScale();
|
updateHudScale();
|
||||||
const brandRoot = document.getElementById("brand-root");
|
const brandRoot = document.getElementById("brand-root");
|
||||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||||
@@ -952,13 +953,14 @@ export function init() {
|
|||||||
setLegendItems("satellites", getSatelliteLegendItems());
|
setLegendItems("satellites", getSatelliteLegendItems());
|
||||||
setLegendItems("bgp", getBGPLegendItems());
|
setLegendItems("bgp", getBGPLegendItems());
|
||||||
const earthObj = createEarth(scene);
|
const earthObj = createEarth(scene);
|
||||||
|
applyImmediateView(earthObj, camera);
|
||||||
targetRotation = {
|
targetRotation = {
|
||||||
x: earthObj.rotation.x,
|
x: earthObj.rotation.x,
|
||||||
y: earthObj.rotation.y,
|
y: earthObj.rotation.y,
|
||||||
};
|
};
|
||||||
inertialVelocity = { x: 0, y: 0 };
|
inertialVelocity = { x: 0, y: 0 };
|
||||||
createClouds(scene, earthObj);
|
createClouds(scene, earthObj);
|
||||||
createTerrain(scene, earthObj, simplex);
|
registerTerrainMesh(createTerrain(earthObj));
|
||||||
initCelestialLayer(scene, {
|
initCelestialLayer(scene, {
|
||||||
camera,
|
camera,
|
||||||
sunLight: sceneLights?.sunLight ?? null,
|
sunLight: sceneLights?.sunLight ?? null,
|
||||||
@@ -969,7 +971,6 @@ export function init() {
|
|||||||
createSatellites(scene, earthObj);
|
createSatellites(scene, earthObj);
|
||||||
|
|
||||||
setupControls(camera, renderer, scene, earthObj);
|
setupControls(camera, renderer, scene, earthObj);
|
||||||
resetView(camera);
|
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
|
||||||
clock.start();
|
clock.start();
|
||||||
@@ -1054,7 +1055,7 @@ async function loadData() {
|
|||||||
setLoadingMessage("正在加载登陆点...");
|
setLoadingMessage("正在加载登陆点...");
|
||||||
await yieldFrame(30);
|
await yieldFrame(30);
|
||||||
try {
|
try {
|
||||||
await loadLandingPoints(scene, earth);
|
await loadLandingPoints(scene, earth, { silent: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({ label: "登陆点", reason: err });
|
errors.push({ label: "登陆点", reason: err });
|
||||||
}
|
}
|
||||||
@@ -1067,7 +1068,9 @@ async function loadData() {
|
|||||||
setLoadingMessage("正在加载海缆...");
|
setLoadingMessage("正在加载海缆...");
|
||||||
await yieldFrame(30);
|
await yieldFrame(30);
|
||||||
try {
|
try {
|
||||||
const cableCount = await loadGeoJSONFromPath(scene, earth);
|
const cableCount = await loadGeoJSONFromPath(scene, earth, {
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
if (loadToken === currentLoadToken && cablesEnabled) {
|
if (loadToken === currentLoadToken && cablesEnabled) {
|
||||||
toggleCables(true);
|
toggleCables(true);
|
||||||
updateCableToggleUi(true);
|
updateCableToggleUi(true);
|
||||||
@@ -1147,10 +1150,10 @@ async function loadData() {
|
|||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
const errorMessage = buildLoadErrorMessage(errors);
|
const errorMessage = buildLoadErrorMessage(errors);
|
||||||
showError(errorMessage);
|
showError(errorMessage);
|
||||||
showStatusMessage(errorMessage, "error");
|
queueStatusMessage(errorMessage, "error");
|
||||||
} else {
|
} else {
|
||||||
hideError();
|
hideError();
|
||||||
showStatusMessage("数据已加载", "success");
|
queueStatusMessage("数据已加载", "success");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1787,6 +1790,7 @@ export function destroy() {
|
|||||||
resetSatelliteState();
|
resetSatelliteState();
|
||||||
clearUiState();
|
clearUiState();
|
||||||
disposeCelestialLayer();
|
disposeCelestialLayer();
|
||||||
|
clearTerrainData();
|
||||||
|
|
||||||
if (scene) {
|
if (scene) {
|
||||||
disposeSceneObject(scene);
|
disposeSceneObject(scene);
|
||||||
|
|||||||
304
frontend/public/earth/js/terrain.js
Normal file
304
frontend/public/earth/js/terrain.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
import { CONFIG, TERRAIN_CONFIG } from "./constants.js";
|
||||||
|
import { vector3ToLatLon } from "./utils.js";
|
||||||
|
|
||||||
|
const EARTH_RADIUS_METERS = 6371000;
|
||||||
|
const TERRAIN_COLOR_STOPS = [
|
||||||
|
{ height: 0, color: new THREE.Color(0x5f7f5b) },
|
||||||
|
{ height: 800, color: new THREE.Color(0x7f9564) },
|
||||||
|
{ height: 1800, color: new THREE.Color(0x9e956c) },
|
||||||
|
{ height: 3200, color: new THREE.Color(0x9f866a) },
|
||||||
|
{ height: 5200, color: new THREE.Color(0xc6c0b1) },
|
||||||
|
{ height: 7800, color: new THREE.Color(0xe8e5de) },
|
||||||
|
];
|
||||||
|
|
||||||
|
let terrainMesh = null;
|
||||||
|
let terrainLoadPromise = null;
|
||||||
|
let terrainReady = false;
|
||||||
|
let terrainFailed = false;
|
||||||
|
let terrainTileCache = new Map();
|
||||||
|
let terrainVertexSamples = null;
|
||||||
|
let terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||||
|
|
||||||
|
function clampLatitude(lat) {
|
||||||
|
return THREE.MathUtils.clamp(lat, -85.05112878, 85.05112878);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTileUrl(z, x, y) {
|
||||||
|
return TERRAIN_CONFIG.urlTemplate
|
||||||
|
.replace("{z}", String(z))
|
||||||
|
.replace("{x}", String(x))
|
||||||
|
.replace("{y}", String(y));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTerrainCanvas(size) {
|
||||||
|
if (typeof OffscreenCanvas !== "undefined") {
|
||||||
|
return new OffscreenCanvas(size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodeTerrainTile(z, x, y) {
|
||||||
|
const cacheKey = `${z}/${x}/${y}`;
|
||||||
|
if (terrainTileCache.has(cacheKey)) {
|
||||||
|
return terrainTileCache.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tilePromise = (async () => {
|
||||||
|
const response = await fetch(buildTileUrl(z, x, y), { mode: "cors" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status} for terrain tile ${cacheKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize);
|
||||||
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize);
|
||||||
|
bitmap.close?.();
|
||||||
|
const { data, width, height } = ctx.getImageData(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
TERRAIN_CONFIG.tileSize,
|
||||||
|
TERRAIN_CONFIG.tileSize,
|
||||||
|
);
|
||||||
|
return { data, width, height };
|
||||||
|
})();
|
||||||
|
|
||||||
|
terrainTileCache.set(cacheKey, tilePromise);
|
||||||
|
return tilePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeTerrariumHeight(tile, pixelX, pixelY) {
|
||||||
|
const safeX = THREE.MathUtils.clamp(pixelX, 0, tile.width - 1);
|
||||||
|
const safeY = THREE.MathUtils.clamp(pixelY, 0, tile.height - 1);
|
||||||
|
const index = (safeY * tile.width + safeX) * 4;
|
||||||
|
const r = tile.data[index];
|
||||||
|
const g = tile.data[index + 1];
|
||||||
|
const b = tile.data[index + 2];
|
||||||
|
return (r * 256 + g + b / 256) - 32768;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latLonToTileSample(lat, lon, z, tileSize) {
|
||||||
|
const n = 2 ** z;
|
||||||
|
const clampedLat = clampLatitude(lat);
|
||||||
|
const latRad = THREE.MathUtils.degToRad(clampedLat);
|
||||||
|
const normalizedX = ((lon + 180) / 360) * n;
|
||||||
|
const normalizedY =
|
||||||
|
((1 -
|
||||||
|
Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) /
|
||||||
|
2) *
|
||||||
|
n;
|
||||||
|
|
||||||
|
const tileX = THREE.MathUtils.euclideanModulo(
|
||||||
|
Math.floor(normalizedX),
|
||||||
|
n,
|
||||||
|
);
|
||||||
|
const tileY = THREE.MathUtils.clamp(Math.floor(normalizedY), 0, n - 1);
|
||||||
|
const pixelX = Math.floor((normalizedX - Math.floor(normalizedX)) * tileSize);
|
||||||
|
const pixelY = Math.floor((normalizedY - Math.floor(normalizedY)) * tileSize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tileX,
|
||||||
|
tileY,
|
||||||
|
pixelX,
|
||||||
|
pixelY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTerrainVertexSamples(positionAttribute) {
|
||||||
|
const z = TERRAIN_CONFIG.baseZoom;
|
||||||
|
const tileSize = TERRAIN_CONFIG.tileSize;
|
||||||
|
const samples = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < positionAttribute.count; i++) {
|
||||||
|
const direction = new THREE.Vector3(
|
||||||
|
positionAttribute.getX(i),
|
||||||
|
positionAttribute.getY(i),
|
||||||
|
positionAttribute.getZ(i),
|
||||||
|
).normalize();
|
||||||
|
const { lat, lon } = vector3ToLatLon(direction);
|
||||||
|
const sample = latLonToTileSample(lat, lon, z, tileSize);
|
||||||
|
samples.push({
|
||||||
|
index: i,
|
||||||
|
direction,
|
||||||
|
...sample,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleTerrainColor(heightMeters) {
|
||||||
|
if (heightMeters <= TERRAIN_COLOR_STOPS[0].height) {
|
||||||
|
return TERRAIN_COLOR_STOPS[0].color;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < TERRAIN_COLOR_STOPS.length; i++) {
|
||||||
|
const lower = TERRAIN_COLOR_STOPS[i - 1];
|
||||||
|
const upper = TERRAIN_COLOR_STOPS[i];
|
||||||
|
if (heightMeters <= upper.height) {
|
||||||
|
const t =
|
||||||
|
(heightMeters - lower.height) / Math.max(upper.height - lower.height, 1);
|
||||||
|
return lower.color.clone().lerp(upper.color, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TERRAIN_COLOR_STOPS[TERRAIN_COLOR_STOPS.length - 1].color;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithConcurrency(items, limit, worker) {
|
||||||
|
const queue = [...items];
|
||||||
|
const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const item = queue.shift();
|
||||||
|
await worker(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRequiredTiles(samples) {
|
||||||
|
const uniqueKeys = Array.from(
|
||||||
|
new Set(samples.map((sample) => `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`)),
|
||||||
|
);
|
||||||
|
const resolvedTiles = new Map();
|
||||||
|
|
||||||
|
await runWithConcurrency(
|
||||||
|
uniqueKeys,
|
||||||
|
TERRAIN_CONFIG.maxConcurrentRequests,
|
||||||
|
async (key) => {
|
||||||
|
const [z, x, y] = key.split("/").map(Number);
|
||||||
|
resolvedTiles.set(key, await decodeTerrainTile(z, x, y));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return resolvedTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTerrainDisplacement(samples, mesh, resolvedTiles) {
|
||||||
|
const geometry = mesh.geometry;
|
||||||
|
const positionAttribute = geometry.getAttribute("position");
|
||||||
|
let colorAttribute = geometry.getAttribute("color");
|
||||||
|
if (!colorAttribute || colorAttribute.itemSize !== 4) {
|
||||||
|
colorAttribute = new THREE.BufferAttribute(
|
||||||
|
new Float32Array(positionAttribute.count * 4),
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
geometry.setAttribute("color", colorAttribute);
|
||||||
|
}
|
||||||
|
const baseRadius = CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset;
|
||||||
|
|
||||||
|
samples.forEach((sample) => {
|
||||||
|
const tileKey = `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`;
|
||||||
|
const tile = resolvedTiles.get(tileKey);
|
||||||
|
if (!tile) return;
|
||||||
|
const rawElevationMeters = decodeTerrariumHeight(
|
||||||
|
tile,
|
||||||
|
sample.pixelX,
|
||||||
|
sample.pixelY,
|
||||||
|
);
|
||||||
|
const elevationMeters = Math.max(0, rawElevationMeters);
|
||||||
|
const heightWorld =
|
||||||
|
(elevationMeters / EARTH_RADIUS_METERS) *
|
||||||
|
CONFIG.earthRadius *
|
||||||
|
TERRAIN_CONFIG.exaggeration;
|
||||||
|
const radius = baseRadius + heightWorld;
|
||||||
|
const tint = sampleTerrainColor(elevationMeters);
|
||||||
|
const landAlpha = THREE.MathUtils.clamp(
|
||||||
|
elevationMeters / Math.max(TERRAIN_CONFIG.landRevealFadeMeters, 1),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
positionAttribute.setXYZ(
|
||||||
|
sample.index,
|
||||||
|
sample.direction.x * radius,
|
||||||
|
sample.direction.y * radius,
|
||||||
|
sample.direction.z * radius,
|
||||||
|
);
|
||||||
|
colorAttribute.setXYZW(sample.index, tint.r, tint.g, tint.b, landAlpha);
|
||||||
|
});
|
||||||
|
|
||||||
|
positionAttribute.needsUpdate = true;
|
||||||
|
colorAttribute.needsUpdate = true;
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
geometry.computeBoundingSphere();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerTerrainMesh(mesh) {
|
||||||
|
terrainMesh = mesh;
|
||||||
|
terrainReady = false;
|
||||||
|
terrainFailed = false;
|
||||||
|
terrainLoadPromise = null;
|
||||||
|
terrainTileCache = new Map();
|
||||||
|
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||||
|
if (terrainMesh?.material) {
|
||||||
|
terrainMesh.material.opacity = terrainOpacity;
|
||||||
|
terrainMesh.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
terrainVertexSamples = mesh
|
||||||
|
? buildTerrainVertexSamples(mesh.geometry.getAttribute("position"))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTerrainReady() {
|
||||||
|
return terrainReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureTerrainReady() {
|
||||||
|
if (!terrainMesh || !TERRAIN_CONFIG.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (terrainReady) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (terrainLoadPromise) {
|
||||||
|
return terrainLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
terrainLoadPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const resolvedTiles = await fetchRequiredTiles(terrainVertexSamples);
|
||||||
|
applyTerrainDisplacement(terrainVertexSamples, terrainMesh, resolvedTiles);
|
||||||
|
terrainReady = true;
|
||||||
|
terrainFailed = false;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
terrainFailed = true;
|
||||||
|
console.error("加载真实地形失败:", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
terrainLoadPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return terrainLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTerrainData() {
|
||||||
|
terrainMesh = null;
|
||||||
|
terrainLoadPromise = null;
|
||||||
|
terrainReady = false;
|
||||||
|
terrainFailed = false;
|
||||||
|
terrainVertexSamples = null;
|
||||||
|
terrainTileCache = new Map();
|
||||||
|
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTerrainOpacity(nextOpacity) {
|
||||||
|
terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1);
|
||||||
|
if (terrainMesh?.material) {
|
||||||
|
terrainMesh.material.opacity = terrainOpacity;
|
||||||
|
terrainMesh.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
return terrainOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTerrainOpacity() {
|
||||||
|
return terrainOpacity;
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ let statusQueue = [];
|
|||||||
let statusBusy = false;
|
let statusBusy = false;
|
||||||
let loadingActive = false;
|
let loadingActive = false;
|
||||||
let loadingLockedWidth = 0;
|
let loadingLockedWidth = 0;
|
||||||
|
let pendingLoadingMessage = "";
|
||||||
|
|
||||||
|
function createStatusEntry(message, type = "info") {
|
||||||
|
return { message, type };
|
||||||
|
}
|
||||||
|
|
||||||
function getElement(id) {
|
function getElement(id) {
|
||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
@@ -113,7 +118,15 @@ function startTransientStatus(message, type = "info") {
|
|||||||
|
|
||||||
// Show status message
|
// Show status message
|
||||||
export function showStatusMessage(message, type = "info") {
|
export function showStatusMessage(message, type = "info") {
|
||||||
statusQueue.push({ message, type });
|
if (loadingActive) {
|
||||||
|
statusQueue.unshift(createStatusEntry(message, type));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransientStatus(message, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueStatusMessage(message, type = "info") {
|
||||||
|
statusQueue.push(createStatusEntry(message, type));
|
||||||
processStatusQueue();
|
processStatusQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +192,12 @@ export function setLoading(loading) {
|
|||||||
loadingActive = true;
|
loadingActive = true;
|
||||||
statusBusy = false;
|
statusBusy = false;
|
||||||
clearLoadingWidthLock(statusEl);
|
clearLoadingWidthLock(statusEl);
|
||||||
buildStatusContent(statusEl, "正在加载...", "loading");
|
buildStatusContent(
|
||||||
|
statusEl,
|
||||||
|
pendingLoadingMessage || "正在加载...",
|
||||||
|
"loading",
|
||||||
|
);
|
||||||
|
pendingLoadingMessage = "";
|
||||||
statusEl.className = `${STATUS_BASE_CLASS} loading`;
|
statusEl.className = `${STATUS_BASE_CLASS} loading`;
|
||||||
setElementDisplay(statusEl, true, "inline-flex");
|
setElementDisplay(statusEl, true, "inline-flex");
|
||||||
statusEl.offsetHeight;
|
statusEl.offsetHeight;
|
||||||
@@ -188,6 +206,7 @@ export function setLoading(loading) {
|
|||||||
updateLoadingWidthLock(statusEl);
|
updateLoadingWidthLock(statusEl);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
pendingLoadingMessage = "";
|
||||||
if (!statusEl.classList.contains("loading")) {
|
if (!statusEl.classList.contains("loading")) {
|
||||||
loadingActive = false;
|
loadingActive = false;
|
||||||
clearLoadingWidthLock(statusEl);
|
clearLoadingWidthLock(statusEl);
|
||||||
@@ -205,7 +224,10 @@ export function setLoading(loading) {
|
|||||||
|
|
||||||
export function setLoadingMessage(title) {
|
export function setLoadingMessage(title) {
|
||||||
const statusEl = getElement("status-message");
|
const statusEl = getElement("status-message");
|
||||||
if (!statusEl || !statusEl.classList.contains("loading")) return;
|
if (!statusEl || !statusEl.classList.contains("loading")) {
|
||||||
|
pendingLoadingMessage = title;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const textEl = statusEl.querySelector(".earth-status-text");
|
const textEl = statusEl.querySelector(".earth-status-text");
|
||||||
if (textEl) {
|
if (textEl) {
|
||||||
textEl.textContent = title;
|
textEl.textContent = title;
|
||||||
@@ -255,6 +277,7 @@ export function clearUiState() {
|
|||||||
statusQueue = [];
|
statusQueue = [];
|
||||||
statusBusy = false;
|
statusBusy = false;
|
||||||
loadingActive = false;
|
loadingActive = false;
|
||||||
|
pendingLoadingMessage = "";
|
||||||
|
|
||||||
const statusEl = getElement("status-message");
|
const statusEl = getElement("status-message");
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.29.2"
|
version = "0.30.0"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user