diff --git a/MEMORY.md b/MEMORY.md index 29c244a..f93fce8 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -283,6 +283,21 @@ - 一期采样策略为 CSV 点集最近邻(非栅格插值),用于先打通管理与回填闭环;默认推荐回填模式 `fill_null_only`,避免覆盖人工高程。 - 高频通知 topic 为 `admin.elevation`,线路联动通知沿用 `admin.power-lines`。 - 高程数据文件格式已扩展为:`csv/img/tif/tiff`。 + +## DEM 地形瓦片口径(2026-06-09) + +- 高程数据集在“分析成功”后会自动尝试派发 DEM 地形瓦片构建任务;管理端同时保留手动 `POST /api/v1/elevation/datasets/{id}/terrain/build` 触发入口,用于重试或重建。 +- 地形瓦片产物采用 Cesium `heightmap-1.0` 规范,落盘到数据集目录下的 `terrain/`: + - `terrain/layer.json` + - `terrain/{z}/{x}/{y}.terrain` +- 当前服务口径: + - `GET /api/v1/elevation/datasets/{id}/terrain/layer.json` + - `GET /api/v1/elevation/datasets/{id}/terrain/{z}/{x}/{y}.terrain` +- 前端加载 Cesium DEM 时,必须通过 `Cesium.Resource` 显式附带 `Authorization: Bearer `;不能假设仅靠 refresh cookie 即可访问地形接口。 +- 前端地形地址生成统一复用 `web/src/lib/elevation-terrain.ts`: + - `getElevationTerrainRenderState()` + - `getElevationTerrainLayerUrl()` +- `/admin/elevation` 负责展示地形状态、手动触发构建和预览地图加载真实地形;`/admin/power-lines` 负责选择已接入的 DEM 数据集和垂直夸张倍数,不在页面层重复实现地形地址或状态判断逻辑。 - `csv`:继续使用点集最近邻采样。 - `img/tif/tiff`:使用栅格像元采样(按杆塔经纬度取值,必要时自动做 CRS 转换)。 - 栅格实现口径: diff --git a/api/app/api/v1/elevation.py b/api/app/api/v1/elevation.py index 8fbf603..b3b955d 100644 --- a/api/app/api/v1/elevation.py +++ b/api/app/api/v1/elevation.py @@ -1,6 +1,6 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status from sqlalchemy.orm import Session from ...core.database import get_db @@ -19,6 +19,9 @@ from ...schemas.elevation import ( ElevationDatasetListResponse, ElevationDatasetPreviewResponse, ElevationDatasetSummary, + ElevationDatasetTerrainBuildResponse, + ElevationDatasetTerrainTaskStatusResponse, + ElevationTerrainLayerResponse, ElevationDatasetUpdateRequest, ) from ...services.elevation_service import ( @@ -26,6 +29,9 @@ from ...services.elevation_service import ( create_dataset, delete_dataset, get_dataset_analysis_task_status, + get_dataset_terrain_layer, + get_dataset_terrain_task_status, + get_dataset_terrain_tile, get_job_by_id, import_dataset_data_files, import_datasets_from_csv, @@ -34,6 +40,7 @@ from ...services.elevation_service import ( list_jobs, preview_dataset, queue_dataset_analysis, + queue_dataset_terrain_build, serialize_job, update_dataset, ) @@ -129,6 +136,15 @@ def analyze_elevation_dataset( return queue_dataset_analysis(db, dataset_id=dataset_id, actor=current_user.user) +@router.post("/datasets/{dataset_id}/terrain/build", response_model=ElevationDatasetTerrainBuildResponse) +def build_elevation_dataset_terrain( + dataset_id: str, + current_user: CurrentUser = Depends(require_permission("elevation.manage")), + db: Session = Depends(get_db), +) -> ElevationDatasetTerrainBuildResponse: + return queue_dataset_terrain_build(db, dataset_id=dataset_id, actor=current_user.user) + + @router.get("/datasets/{dataset_id}/preview", response_model=ElevationDatasetPreviewResponse) def preview_elevation_dataset( dataset_id: str, @@ -161,6 +177,37 @@ def get_elevation_dataset_analysis_task_status( return get_dataset_analysis_task_status(db, dataset_id=dataset_id) +@router.get("/datasets/{dataset_id}/terrain/status", response_model=ElevationDatasetTerrainTaskStatusResponse) +def get_elevation_dataset_terrain_status( + dataset_id: str, + _: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")), + db: Session = Depends(get_db), +) -> ElevationDatasetTerrainTaskStatusResponse: + return get_dataset_terrain_task_status(db, dataset_id=dataset_id) + + +@router.get("/datasets/{dataset_id}/terrain/layer.json", response_model=ElevationTerrainLayerResponse) +def get_elevation_dataset_terrain_layer( + dataset_id: str, + _: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")), + db: Session = Depends(get_db), +) -> ElevationTerrainLayerResponse: + return get_dataset_terrain_layer(db, dataset_id=dataset_id) + + +@router.get("/datasets/{dataset_id}/terrain/{z}/{x}/{y}.terrain") +def get_elevation_dataset_terrain_tile_endpoint( + dataset_id: str, + z: int, + x: int, + y: int, + _: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")), + db: Session = Depends(get_db), +) -> Response: + content = get_dataset_terrain_tile(db, dataset_id=dataset_id, z=z, x=x, y=y) + return Response(content=content, media_type="application/octet-stream") + + @router.get("/jobs", response_model=ElevationApplyJobListResponse) def get_elevation_jobs( line_id: str | None = Query(default=None), diff --git a/api/app/core/database.py b/api/app/core/database.py index 933aa59..f6fc3ae 100644 --- a/api/app/core/database.py +++ b/api/app/core/database.py @@ -276,6 +276,93 @@ def _ensure_elevation_dataset_column_compatibility() -> None: "Detected missing elevation_dataset.analysis_finished_at; added nullable analysis finish time column.", ) + if "terrain_status" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_status VARCHAR(32)"), + ) + connection.execute( + text( + """ + UPDATE elevation_dataset + SET terrain_status = CASE + WHEN lower(coalesce(file_format, '')) IN ('img', 'tif', 'tiff') THEN 'pending' + ELSE 'not_supported' + END + WHERE terrain_status IS NULL + """ + ), + ) + connection.execute( + text("ALTER TABLE elevation_dataset ALTER COLUMN terrain_status SET NOT NULL"), + ) + logger.warning( + "Detected missing elevation_dataset.terrain_status; added and backfilled by file format.", + ) + + if "terrain_task_id" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_task_id VARCHAR(128)"), + ) + logger.warning( + "Detected missing elevation_dataset.terrain_task_id; added nullable terrain task id column.", + ) + + if "terrain_error_message" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_error_message TEXT"), + ) + logger.warning( + "Detected missing elevation_dataset.terrain_error_message; added nullable terrain error column.", + ) + + if "terrain_root_path" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_root_path VARCHAR(2048)"), + ) + logger.warning( + "Detected missing elevation_dataset.terrain_root_path; added nullable terrain root path column.", + ) + + if "terrain_url_template" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_url_template VARCHAR(2048)"), + ) + logger.warning( + "Detected missing elevation_dataset.terrain_url_template; added nullable terrain URL template column.", + ) + + if "terrain_min_zoom" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_min_zoom INTEGER"), + ) + logger.warning( + "Detected missing elevation_dataset.terrain_min_zoom; added nullable terrain min zoom column.", + ) + + if "terrain_max_zoom" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_max_zoom INTEGER"), + ) + logger.warning( + "Detected missing elevation_dataset.terrain_max_zoom; added nullable terrain max zoom column.", + ) + + if "terrain_bounds" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_bounds JSON"), + ) + logger.warning( + "Detected missing elevation_dataset.terrain_bounds; added nullable terrain bounds column.", + ) + + if "terrain_metadata" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_metadata JSON"), + ) + logger.warning( + "Detected missing elevation_dataset.terrain_metadata; added nullable terrain metadata column.", + ) + def _ensure_atp_simulation_run_column_compatibility() -> None: if not database_url.startswith("postgresql"): diff --git a/api/app/models/elevation.py b/api/app/models/elevation.py index 31a89d5..cd29c78 100644 --- a/api/app/models/elevation.py +++ b/api/app/models/elevation.py @@ -1,10 +1,10 @@ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from uuid import uuid4 -from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text +from sqlalchemy import JSON, DateTime, Float, ForeignKey, Index, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from ..core.database import Base @@ -42,6 +42,15 @@ class ElevationDataset(Base): analysis_error_message: Mapped[str | None] = mapped_column(Text) analysis_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) analysis_finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + terrain_status: Mapped[str] = mapped_column(String(32), default="not_supported", index=True) + terrain_task_id: Mapped[str | None] = mapped_column(String(128), index=True) + terrain_error_message: Mapped[str | None] = mapped_column(Text) + terrain_root_path: Mapped[str | None] = mapped_column(String(2048)) + terrain_url_template: Mapped[str | None] = mapped_column(String(2048)) + terrain_min_zoom: Mapped[int | None] = mapped_column(Integer) + terrain_max_zoom: Mapped[int | None] = mapped_column(Integer) + terrain_bounds: Mapped[dict[str, Any] | None] = mapped_column(JSON) + terrain_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON) sample_count: Mapped[int] = mapped_column(Integer, default=0) bbox_min_lon: Mapped[float | None] = mapped_column(Float) bbox_max_lon: Mapped[float | None] = mapped_column(Float) diff --git a/api/app/schemas/elevation.py b/api/app/schemas/elevation.py index 9e11548..edc5350 100644 --- a/api/app/schemas/elevation.py +++ b/api/app/schemas/elevation.py @@ -1,13 +1,14 @@ from __future__ import annotations from datetime import datetime -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, Field ElevationDatasetStatus = Literal["active", "disabled"] ElevationDatasetUsageStatus = Literal["idle", "in_use"] +ElevationDatasetTerrainStatus = Literal["pending", "processing", "ready", "failed", "not_supported"] ElevationApplyMode = Literal["fill_null_only", "overwrite_all"] ElevationApplyJobStatus = Literal["pending", "running", "success", "failed"] @@ -34,6 +35,15 @@ class ElevationDatasetSummary(BaseModel): analysis_error_message: str | None = None analysis_started_at: datetime | None = None analysis_finished_at: datetime | None = None + terrain_status: ElevationDatasetTerrainStatus = "not_supported" + terrain_task_id: str | None = None + terrain_error_message: str | None = None + terrain_root_path: str | None = None + terrain_url_template: str | None = None + terrain_min_zoom: int | None = None + terrain_max_zoom: int | None = None + terrain_bounds: dict[str, Any] | None = None + terrain_metadata: dict[str, Any] | None = None notes: str | None = None create_date: datetime create_user: str | None = None @@ -81,6 +91,14 @@ class ElevationDatasetAnalyzeResponse(BaseModel): warnings: list[str] = Field(default_factory=list) +class ElevationDatasetTerrainBuildResponse(BaseModel): + dataset: ElevationDatasetSummary + task_id: str | None = None + queued: bool = True + detail: str | None = None + warnings: list[str] = Field(default_factory=list) + + class ElevationDatasetPreviewPoint(BaseModel): longitude: float latitude: float @@ -173,6 +191,32 @@ class ElevationDatasetAnalysisTaskStatusResponse(BaseModel): update_date: datetime | None = None +class ElevationDatasetTerrainTaskStatusResponse(BaseModel): + dataset_id: str + dataset_code: str + task_id: str | None = None + status: Literal["queued", "running", "success", "failed", "unknown", "not_found"] = "not_found" + detail: str | None = None + terrain_url_template: str | None = None + terrain_min_zoom: int | None = None + terrain_max_zoom: int | None = None + update_date: datetime | None = None + + +class ElevationTerrainLayerResponse(BaseModel): + tilejson: str = "2.1.0" + format: str = "heightmap-1.0" + version: str = "1.0.0" + scheme: Literal["tms"] = "tms" + projection: Literal["EPSG:4326"] = "EPSG:4326" + tiles: list[str] + maxzoom: int + extensions: list[str] = Field(default_factory=list) + attribution: str | None = None + bounds: list[float] | None = None + available: list[list[dict[str, int]]] | None = None + + class ElevationApplyJobSummary(BaseModel): id: str line_id: str diff --git a/api/app/services/elevation_service.py b/api/app/services/elevation_service.py index 3e2a8cf..dc98f69 100644 --- a/api/app/services/elevation_service.py +++ b/api/app/services/elevation_service.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio import csv import io +import json +import math import mimetypes import zipfile from dataclasses import dataclass @@ -29,6 +31,9 @@ from ..schemas.elevation import ( ElevationDatasetAnalyzeResponse, ElevationDatasetBatchImportResponse, ElevationDatasetDataImportResponse, + ElevationDatasetTerrainBuildResponse, + ElevationDatasetTerrainTaskStatusResponse, + ElevationTerrainLayerResponse, ElevationDatasetPreviewCell, ElevationDatasetPreviewDiagnostics, ElevationDatasetCreateRequest, @@ -61,6 +66,22 @@ RASTER_FILE_FORMATS = {"img", "tif", "tiff"} IMPORTABLE_ELEVATION_EXTENSIONS = set(ELEVATION_FILE_EXT_FORMAT_MAP.keys()) IMPORTABLE_ARCHIVE_EXTENSIONS = {".zip"} MAX_SAMPLE_COUNT_INT = 2_147_483_647 +TERRAIN_TILE_SIZE = 65 +TERRAIN_ROOT_DIRNAME = "terrain" +TERRAIN_TILE_VERSION = "1.0.0" +TERRAIN_TILE_FORMAT = "heightmap-1.0" +TERRAIN_TILE_PROJECTION = "EPSG:4326" +TERRAIN_DEFAULT_MIN_ZOOM = 0 +TERRAIN_DEFAULT_MAX_ZOOM = 6 +TERRAIN_MAX_ALLOWED_ZOOM = 10 +TERRAIN_CHILD_MASK_SW = 1 +TERRAIN_CHILD_MASK_SE = 2 +TERRAIN_CHILD_MASK_NW = 4 +TERRAIN_CHILD_MASK_NE = 8 +TERRAIN_WATER_MASK_ALL_LAND = b"\x00" +TERRAIN_CONTENT_TYPE = "application/octet-stream" +TERRAIN_SUPPORTED_DATASET_FORMATS = RASTER_FILE_FORMATS +TERRAIN_BUILD_TOOL = "builtin-heightmap-1.0" @dataclass @@ -105,6 +126,14 @@ class ElevationDatasetBatchImportStats: self.items = [] +@dataclass +class _TerrainBuildArtifacts: + min_zoom: int + max_zoom: int + bounds: dict[str, float] + metadata: dict[str, Any] + + def serialize_dataset(item: ElevationDataset) -> ElevationDatasetSummary: return ElevationDatasetSummary( id=item.id, @@ -128,6 +157,15 @@ def serialize_dataset(item: ElevationDataset) -> ElevationDatasetSummary: analysis_error_message=item.analysis_error_message, analysis_started_at=item.analysis_started_at, analysis_finished_at=item.analysis_finished_at, + terrain_status=item.terrain_status, # type: ignore[arg-type] + terrain_task_id=item.terrain_task_id, + terrain_error_message=item.terrain_error_message, + terrain_root_path=item.terrain_root_path, + terrain_url_template=item.terrain_url_template, + terrain_min_zoom=item.terrain_min_zoom, + terrain_max_zoom=item.terrain_max_zoom, + terrain_bounds=item.terrain_bounds, + terrain_metadata=item.terrain_metadata, notes=item.notes, create_date=item.create_date, create_user=item.create_user, @@ -216,6 +254,60 @@ def get_dataset_by_code(db: Session, code: str) -> ElevationDataset | None: ).scalar_one_or_none() +def _supports_terrain_build(dataset: ElevationDataset) -> bool: + return _resolve_dataset_file_format(dataset) in TERRAIN_SUPPORTED_DATASET_FORMATS + + +def _default_terrain_status_for_format(file_format: str) -> str: + return "pending" if file_format in TERRAIN_SUPPORTED_DATASET_FORMATS else "not_supported" + + +def _sync_dataset_terrain_support(dataset: ElevationDataset) -> None: + file_format = _resolve_dataset_file_format(dataset) + if file_format in TERRAIN_SUPPORTED_DATASET_FORMATS: + if dataset.terrain_status == "not_supported": + dataset.terrain_status = "pending" + return + + dataset.terrain_status = "not_supported" + dataset.terrain_task_id = None + dataset.terrain_error_message = None + dataset.terrain_root_path = None + dataset.terrain_url_template = None + dataset.terrain_min_zoom = None + dataset.terrain_max_zoom = None + dataset.terrain_bounds = None + dataset.terrain_metadata = None + + +def _resolve_dataset_terrain_dir(dataset_code: str) -> str: + return join_virtual_path(_resolve_dataset_dir(dataset_code), TERRAIN_ROOT_DIRNAME) + + +def _resolve_dataset_terrain_tile_path(*, dataset_code: str, z: int, x: int, y: int) -> str: + return join_virtual_path(join_virtual_path(join_virtual_path(_resolve_dataset_terrain_dir(dataset_code), str(z)), str(x)), f"{y}.terrain") + + +def _build_dataset_terrain_url_template(dataset_id: str) -> str: + return f"/api/v1/elevation/datasets/{dataset_id}/terrain/{{z}}/{{x}}/{{y}}.terrain?v={TERRAIN_TILE_VERSION}" + + +def _map_dataset_task_status(status_value: str | None) -> str: + status_map = { + "queued": "queued", + "pending": "queued", + "running": "running", + "processing": "running", + "success": "success", + "ready": "success", + "failed": "failed", + "unknown": "unknown", + "not_started": "not_found", + "not_supported": "not_found", + } + return status_map.get(status_value or "", "unknown") + + def list_dataset_files( db: Session, *, @@ -307,6 +399,7 @@ def create_dataset( resolution_m=payload.resolution_m, status="active", usage_status="idle", + terrain_status=_default_terrain_status_for_format(file_format), notes=_normalize_str(payload.notes), create_date=now, create_user=actor.id, @@ -488,6 +581,15 @@ def import_dataset_data_files( dataset.file_path = preferred_file_path dataset.file_format = _detect_file_format(preferred_file_path) dataset.usage_status = "idle" + dataset.terrain_status = _default_terrain_status_for_format(dataset.file_format) + dataset.terrain_task_id = None + dataset.terrain_error_message = None + dataset.terrain_root_path = None + dataset.terrain_url_template = None + dataset.terrain_min_zoom = None + dataset.terrain_max_zoom = None + dataset.terrain_bounds = None + dataset.terrain_metadata = None dataset.update_user = actor.id dataset.update_date = utcnow() db.commit() @@ -570,6 +672,172 @@ def get_dataset_analysis_task_status( ) +def get_dataset_terrain_task_status( + db: Session, + *, + dataset_id: str, +) -> ElevationDatasetTerrainTaskStatusResponse: + dataset = get_dataset_by_id(db, dataset_id) + if not dataset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在") + + mapped_status = _map_dataset_task_status(dataset.terrain_status) + detail = dataset.terrain_error_message + if detail is None: + if mapped_status == "queued": + detail = "地形瓦片任务已提交,等待执行。" + elif mapped_status == "running": + detail = "地形瓦片生成中。" + elif mapped_status == "success": + detail = "地形瓦片已就绪。" + elif dataset.terrain_status == "not_supported": + detail = "当前数据集格式不支持地形瓦片生成。" + + return ElevationDatasetTerrainTaskStatusResponse( + dataset_id=dataset.id, + dataset_code=dataset.code, + task_id=dataset.terrain_task_id, + status=mapped_status, # type: ignore[arg-type] + detail=detail, + terrain_url_template=dataset.terrain_url_template, + terrain_min_zoom=dataset.terrain_min_zoom, + terrain_max_zoom=dataset.terrain_max_zoom, + update_date=dataset.update_date, + ) + + +def queue_dataset_terrain_build( + db: Session, + *, + dataset_id: str, + actor: User, +) -> ElevationDatasetTerrainBuildResponse: + item = get_dataset_by_id(db, dataset_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在") + if item.status != "active": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="高程数据集未启用") + if not _supports_terrain_build(item): + item.terrain_status = "not_supported" + item.terrain_task_id = None + item.terrain_error_message = None + item.update_user = actor.id + item.update_date = utcnow() + db.commit() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前数据集格式不支持地形瓦片生成") + + if item.terrain_status == "processing" or (item.terrain_status == "pending" and item.terrain_task_id): + return ElevationDatasetTerrainBuildResponse( + dataset=serialize_dataset(item), + task_id=item.terrain_task_id, + queued=False, + detail="地形瓦片任务已存在,无需重复提交。", + warnings=[], + ) + + item.terrain_status = "pending" + item.terrain_error_message = None + item.terrain_root_path = None + item.terrain_url_template = None + item.terrain_min_zoom = None + item.terrain_max_zoom = None + item.terrain_bounds = None + item.terrain_metadata = None + item.update_user = actor.id + item.update_date = utcnow() + db.commit() + + try: + task = _dispatch_elevation_dataset_terrain_task(dataset_id=item.id, actor_user_id=actor.id) + except Exception as exc: + item.terrain_status = "failed" + item.terrain_error_message = str(exc) + item.terrain_task_id = None + item.update_user = actor.id + item.update_date = utcnow() + db.commit() + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"地形瓦片任务派发失败: {exc}") from exc + + item.terrain_task_id = str(task.id) + item.update_user = actor.id + item.update_date = utcnow() + db.commit() + + saved = get_dataset_by_id(db, dataset_id) + if not saved: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="地形瓦片任务保存失败") + + _publish_elevation_change( + "elevation.dataset.terrain.queued", + {"action": "dataset_terrain_queued", "dataset_id": saved.id, "task_id": saved.terrain_task_id}, + ) + return ElevationDatasetTerrainBuildResponse( + dataset=serialize_dataset(saved), + task_id=saved.terrain_task_id, + queued=True, + detail="地形瓦片任务已提交,等待执行。", + warnings=[], + ) + + +def get_dataset_terrain_layer( + db: Session, + *, + dataset_id: str, +) -> ElevationTerrainLayerResponse: + dataset = get_dataset_by_id(db, dataset_id) + if not dataset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在") + if dataset.terrain_status != "ready" or not dataset.terrain_root_path: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形瓦片尚未就绪") + + mount = _require_mount(db, dataset.mount_code) + driver = _build_driver_or_400(mount) + layer_path = _resolve_dataset_terrain_layer_path(dataset.code) + try: + payload = driver.read_file(layer_path) + except StoragePathNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形 layer.json 不存在") from exc + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + try: + data = json.loads(payload.content.decode("utf-8")) + except Exception as exc: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="地形 layer.json 解析失败") from exc + return ElevationTerrainLayerResponse.model_validate(data) + + +def get_dataset_terrain_tile( + db: Session, + *, + dataset_id: str, + z: int, + x: int, + y: int, +) -> bytes: + dataset = get_dataset_by_id(db, dataset_id) + if not dataset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在") + if dataset.terrain_status != "ready" or not dataset.terrain_root_path: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形瓦片尚未就绪") + + mount = _require_mount(db, dataset.mount_code) + driver = _build_driver_or_400(mount) + tile_path = _resolve_dataset_terrain_tile_path(dataset_code=dataset.code, z=z, x=x, y=y) + try: + payload = driver.read_file(tile_path) + except StoragePathNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形瓦片不存在") from exc + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + return payload.content + + def update_dataset( db: Session, dataset_id: str, @@ -593,6 +861,7 @@ def update_dataset( if "notes" in update_data: item.notes = _normalize_str(update_data["notes"]) + _sync_dataset_terrain_support(item) item.update_user = actor.id item.update_date = utcnow() db.commit() @@ -676,6 +945,8 @@ def analyze_dataset( item.bbox_max_lon = stats["bbox_max_lon"] item.bbox_min_lat = stats["bbox_min_lat"] item.bbox_max_lat = stats["bbox_max_lat"] + if _supports_terrain_build(item) and item.terrain_status == "not_supported": + item.terrain_status = "pending" item.analysis_status = "success" item.analysis_error_message = None item.analysis_finished_at = utcnow() @@ -691,6 +962,7 @@ def analyze_dataset( "elevation.dataset.analyzed", {"action": "dataset_analyzed", "dataset_id": saved.id}, ) + _queue_dataset_terrain_build_after_analysis(db, dataset=saved, actor_user_id=actor.id) return ElevationDatasetAnalyzeResponse( dataset=serialize_dataset(saved), task_id=saved.analysis_task_id, @@ -932,6 +1204,12 @@ def _dispatch_elevation_dataset_analysis_task(*, dataset_id: str, actor_user_id: return analyze_elevation_dataset_job.delay(dataset_id, actor_user_id) +def _dispatch_elevation_dataset_terrain_task(*, dataset_id: str, actor_user_id: str | None): + from ..tasks.elevation_tasks import build_elevation_dataset_terrain_job + + return build_elevation_dataset_terrain_job.delay(dataset_id, actor_user_id) + + def execute_apply_job(job_id: str) -> None: db = SessionLocal() try: @@ -1117,6 +1395,105 @@ def execute_dataset_analysis_job(*, dataset_id: str, actor_user_id: str | None) db.close() +def execute_dataset_terrain_build_job(*, dataset_id: str, actor_user_id: str | None) -> None: + db = SessionLocal() + try: + item = get_dataset_by_id(db, dataset_id) + if not item: + return + if not _supports_terrain_build(item): + item.terrain_status = "not_supported" + item.terrain_task_id = None + item.terrain_error_message = None + item.update_date = utcnow() + db.commit() + return + + item.terrain_status = "processing" + item.terrain_error_message = None + item.update_date = utcnow() + db.commit() + _publish_elevation_change( + "elevation.dataset.terrain.running", + {"action": "dataset_terrain_running", "dataset_id": item.id}, + ) + + actor = db.execute(select(User).where(User.id == actor_user_id)).scalar_one_or_none() if actor_user_id else None + if actor is None: + actor = db.execute(select(User).where(User.status == "active").order_by(User.id.asc())).scalars().first() + if actor is None: + item.terrain_status = "failed" + item.terrain_error_message = "未找到可用用户执行地形瓦片生成" + item.update_date = utcnow() + db.commit() + _publish_elevation_change( + "elevation.dataset.terrain.failed", + {"action": "dataset_terrain_failed", "dataset_id": item.id}, + ) + return + + artifacts = _build_dataset_terrain_tiles(db, item) + saved = get_dataset_by_id(db, dataset_id) + if saved is None: + return + saved.terrain_status = "ready" + saved.terrain_error_message = None + saved.terrain_root_path = _resolve_dataset_terrain_dir(saved.code) + saved.terrain_url_template = _build_dataset_terrain_url_template(saved.id) + saved.terrain_min_zoom = artifacts.min_zoom + saved.terrain_max_zoom = artifacts.max_zoom + saved.terrain_bounds = artifacts.bounds + saved.terrain_metadata = artifacts.metadata + saved.update_date = utcnow() + saved.update_user = actor.id + db.commit() + _publish_elevation_change( + "elevation.dataset.terrain.ready", + {"action": "dataset_terrain_ready", "dataset_id": saved.id}, + ) + except Exception as exc: + failed = get_dataset_by_id(db, dataset_id) + if failed is not None: + failed.terrain_status = "failed" + failed.terrain_error_message = str(exc) + failed.update_date = utcnow() + db.commit() + _publish_elevation_change( + "elevation.dataset.terrain.failed", + {"action": "dataset_terrain_failed", "dataset_id": failed.id}, + ) + raise + finally: + db.close() + + +def _queue_dataset_terrain_build_after_analysis(db: Session, *, dataset: ElevationDataset, actor_user_id: str | None) -> None: + if not _supports_terrain_build(dataset): + return + if dataset.terrain_status in {"processing", "ready"}: + return + if dataset.terrain_status == "pending" and dataset.terrain_task_id: + return + dataset.terrain_status = "pending" + dataset.terrain_error_message = None + dataset.update_date = utcnow() + db.commit() + try: + task = _dispatch_elevation_dataset_terrain_task(dataset_id=dataset.id, actor_user_id=actor_user_id) + dataset.terrain_task_id = str(task.id) + dataset.update_date = utcnow() + db.commit() + _publish_elevation_change( + "elevation.dataset.terrain.queued", + {"action": "dataset_terrain_queued", "dataset_id": dataset.id, "task_id": dataset.terrain_task_id}, + ) + except Exception as exc: + dataset.terrain_status = "failed" + dataset.terrain_error_message = str(exc) + dataset.update_date = utcnow() + db.commit() + + def _ensure_dataset_file_exists(db: Session, *, mount_code: str, file_path: str) -> None: mount = _require_mount(db, mount_code.strip()) driver = _build_driver_or_400(mount) @@ -1152,6 +1529,10 @@ def _resolve_dataset_file_path(*, dataset_code: str, filename: str | None) -> st return join_virtual_path(dataset_dir, normalized_name) +def _resolve_dataset_terrain_layer_path(dataset_code: str) -> str: + return join_virtual_path(_resolve_dataset_terrain_dir(dataset_code), "layer.json") + + def _normalize_dataset_code(value: str) -> str: code = value.strip() if not code: @@ -1839,6 +2220,329 @@ def _compute_raster_stats( ) +def _compute_raster_wgs84_bounds(*, rasterio: Any, src: Any) -> dict[str, float]: + source_bounds = src.bounds + if src.crs is None or str(src.crs) in {"EPSG:4326", "OGC:CRS84"}: + return _clamp_bounds_to_wgs84( + { + "west": float(source_bounds.left), + "south": float(source_bounds.bottom), + "east": float(source_bounds.right), + "north": float(source_bounds.top), + } + ) + + xs, ys = rasterio.warp.transform( + src.crs, + "EPSG:4326", + [float(source_bounds.left), float(source_bounds.right), float(source_bounds.left), float(source_bounds.right)], + [float(source_bounds.bottom), float(source_bounds.bottom), float(source_bounds.top), float(source_bounds.top)], + ) + return _clamp_bounds_to_wgs84( + { + "west": float(min(xs)), + "south": float(min(ys)), + "east": float(max(xs)), + "north": float(max(ys)), + } + ) + + +def _clamp_bounds_to_wgs84(bounds: dict[str, float]) -> dict[str, float]: + west = max(-180.0, min(180.0, float(bounds["west"]))) + east = max(-180.0, min(180.0, float(bounds["east"]))) + south = max(-90.0, min(90.0, float(bounds["south"]))) + north = max(-90.0, min(90.0, float(bounds["north"]))) + if east <= west: + east = min(180.0, west + 1e-6) + if north <= south: + north = min(90.0, south + 1e-6) + return { + "west": west, + "south": south, + "east": east, + "north": north, + } + + +def _resolve_terrain_zoom_limits(*, bounds: dict[str, float], resolution_m: float | None) -> tuple[int, int]: + min_zoom = TERRAIN_DEFAULT_MIN_ZOOM + if resolution_m is None or resolution_m <= 0: + base_max_zoom = TERRAIN_DEFAULT_MAX_ZOOM + else: + meters_per_sample = max(float(resolution_m), 1.0) + numerator = 180.0 * 111_320.0 + denominator = max((TERRAIN_TILE_SIZE - 1) * meters_per_sample, 1.0) + base_max_zoom = int(math.floor(math.log2(max(numerator / denominator, 1.0)))) + base_max_zoom = max(TERRAIN_DEFAULT_MIN_ZOOM, min(TERRAIN_MAX_ALLOWED_ZOOM, base_max_zoom)) + + max_zoom = max(min_zoom, base_max_zoom) + while max_zoom > min_zoom: + availability = _build_terrain_available_ranges(bounds=bounds, max_zoom=max_zoom) + tile_count = sum((tile_range["endX"] - tile_range["startX"] + 1) * (tile_range["endY"] - tile_range["startY"] + 1) for ranges in availability.values() for tile_range in ranges) + if tile_count <= 512: + break + max_zoom -= 1 + + return min_zoom, max_zoom + + +def _tile_counts(level: int) -> tuple[int, int]: + return 1 << (level + 1), 1 << level + + +def _tile_span_degrees(level: int) -> float: + return 180.0 / float(1 << level) + + +def _build_terrain_available_ranges(*, bounds: dict[str, float], max_zoom: int) -> dict[int, list[dict[str, int]]]: + ranges: dict[int, list[dict[str, int]]] = { + 0: [{"startX": 0, "endX": 1, "startY": 0, "endY": 0}], + } + epsilon = 1e-9 + for level in range(1, max_zoom + 1): + x_count, y_count = _tile_counts(level) + span = _tile_span_degrees(level) + start_x = int(math.floor((bounds["west"] + 180.0) / span)) + end_x = int(math.floor((bounds["east"] + 180.0 - epsilon) / span)) + start_y = int(math.floor((bounds["south"] + 90.0) / span)) + end_y = int(math.floor((bounds["north"] + 90.0 - epsilon) / span)) + start_x = max(0, min(x_count - 1, start_x)) + end_x = max(0, min(x_count - 1, end_x)) + start_y = max(0, min(y_count - 1, start_y)) + end_y = max(0, min(y_count - 1, end_y)) + if end_x < start_x or end_y < start_y: + continue + ranges[level] = [{ + "startX": start_x, + "endX": end_x, + "startY": start_y, + "endY": end_y, + }] + return ranges + + +def _tile_bounds_from_tms(level: int, x: int, y: int) -> dict[str, float]: + span = _tile_span_degrees(level) + west = -180.0 + x * span + east = west + span + south = -90.0 + y * span + north = south + span + return { + "west": west, + "south": south, + "east": east, + "north": north, + } + + +def _tile_intersects_bounds(tile_bounds: dict[str, float], bounds: dict[str, float]) -> bool: + return not ( + tile_bounds["east"] <= bounds["west"] + or tile_bounds["west"] >= bounds["east"] + or tile_bounds["north"] <= bounds["south"] + or tile_bounds["south"] >= bounds["north"] + ) + + +def _range_contains_tile(tile_range: dict[str, int], *, x: int, y: int) -> bool: + return tile_range["startX"] <= x <= tile_range["endX"] and tile_range["startY"] <= y <= tile_range["endY"] + + +def _build_terrain_child_mask(*, availability: dict[int, list[dict[str, int]]], level: int, x: int, y: int, max_zoom: int) -> int: + if level >= max_zoom: + return 0 + + next_ranges = availability.get(level + 1, []) + if not next_ranges: + return 0 + + mask = 0 + children = ( + (TERRAIN_CHILD_MASK_SW, 2 * x, 2 * y), + (TERRAIN_CHILD_MASK_SE, 2 * x + 1, 2 * y), + (TERRAIN_CHILD_MASK_NW, 2 * x, 2 * y + 1), + (TERRAIN_CHILD_MASK_NE, 2 * x + 1, 2 * y + 1), + ) + for bit, child_x, child_y in children: + if any(_range_contains_tile(tile_range, x=child_x, y=child_y) for tile_range in next_ranges): + mask |= bit + return mask + + +def _ensure_virtual_directory(driver: Any, path: str) -> None: + try: + driver.ensure_directory(path) + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + +def _delete_virtual_directory_if_exists(driver: Any, path: str) -> None: + try: + driver.delete_path(path, is_dir=True, recursive=True) + except StoragePathNotFoundError: + return + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + +def _write_virtual_file(driver: Any, *, path: str, content: bytes, content_type: str | None) -> None: + parent_path = str(Path(path).parent).replace("\\", "/") + if not parent_path.startswith("/"): + parent_path = f"/{parent_path}" + _ensure_virtual_directory(driver, parent_path) + try: + driver.write_file(path, content=content, content_type=content_type) + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + +def _build_dataset_terrain_tiles(db: Session, dataset: ElevationDataset) -> _TerrainBuildArtifacts: + mount = _require_mount(db, dataset.mount_code) + driver = _build_driver_or_400(mount) + terrain_dir = _resolve_dataset_terrain_dir(dataset.code) + + _delete_virtual_directory_if_exists(driver, terrain_dir) + _ensure_virtual_directory(driver, terrain_dir) + + with _open_raster_dataset(db, dataset) as opened: + rasterio = opened.rasterio + src = opened.dataset + bounds = _compute_raster_wgs84_bounds(rasterio=rasterio, src=src) + min_zoom, max_zoom = _resolve_terrain_zoom_limits(bounds=bounds, resolution_m=dataset.resolution_m) + availability = _build_terrain_available_ranges(bounds=bounds, max_zoom=max_zoom) + + tile_count = 0 + for level in range(min_zoom, max_zoom + 1): + ranges = availability.get(level, []) + for tile_range in ranges: + for tile_x in range(tile_range["startX"], tile_range["endX"] + 1): + for tile_y in range(tile_range["startY"], tile_range["endY"] + 1): + tile_count += 1 + tile_bounds = _tile_bounds_from_tms(level, tile_x, tile_y) + child_mask = _build_terrain_child_mask( + availability=availability, + level=level, + x=tile_x, + y=tile_y, + max_zoom=max_zoom, + ) + tile_bytes = _generate_heightmap_tile_bytes( + rasterio=rasterio, + src=src, + tile_bounds=tile_bounds, + child_mask=child_mask, + is_blank_root=level == 0 and not _tile_intersects_bounds(tile_bounds, bounds), + ) + tile_path = _resolve_dataset_terrain_tile_path(dataset_code=dataset.code, z=level, x=tile_x, y=tile_y) + _write_virtual_file(driver, path=tile_path, content=tile_bytes, content_type=TERRAIN_CONTENT_TYPE) + + layer_payload = ElevationTerrainLayerResponse( + tiles=[f"{{z}}/{{x}}/{{y}}.terrain?v={TERRAIN_TILE_VERSION}"], + maxzoom=max_zoom, + attribution=f"{dataset.code} {dataset.name}", + bounds=[bounds["west"], bounds["south"], bounds["east"], bounds["north"]], + available=[availability.get(level, []) for level in range(0, max_zoom + 1)], + ).model_dump(mode="json") + _write_virtual_file( + driver, + path=_resolve_dataset_terrain_layer_path(dataset.code), + content=json.dumps(layer_payload, ensure_ascii=True, separators=(",", ":")).encode("utf-8"), + content_type="application/json", + ) + + metadata = { + "tool": TERRAIN_BUILD_TOOL, + "format": TERRAIN_TILE_FORMAT, + "projection": TERRAIN_TILE_PROJECTION, + "tile_size": TERRAIN_TILE_SIZE, + "generated_at": utcnow().isoformat(), + "source_crs": str(src.crs) if src.crs is not None else None, + "source_nodata": src.nodatavals[0] if src.nodatavals else None, + "resolution_m": dataset.resolution_m, + "tile_count": tile_count, + "layer_url": f"/api/v1/elevation/datasets/{dataset.id}/terrain/layer.json", + "terrain_url_template": _build_dataset_terrain_url_template(dataset.id), + } + return _TerrainBuildArtifacts( + min_zoom=min_zoom, + max_zoom=max_zoom, + bounds=bounds, + metadata=metadata, + ) + + +def _generate_heightmap_tile_bytes( + *, + rasterio: Any, + src: Any, + tile_bounds: dict[str, float], + child_mask: int, + is_blank_root: bool, +) -> bytes: + import numpy as np + + if is_blank_root: + heights = np.full((TERRAIN_TILE_SIZE, TERRAIN_TILE_SIZE), 0.0, dtype="float32") + else: + destination = np.full((TERRAIN_TILE_SIZE, TERRAIN_TILE_SIZE), np.nan, dtype="float32") + destination_transform = rasterio.transform.from_bounds( + tile_bounds["west"], + tile_bounds["south"], + tile_bounds["east"], + tile_bounds["north"], + TERRAIN_TILE_SIZE, + TERRAIN_TILE_SIZE, + ) + band_nodata = src.nodatavals[0] if src.nodatavals else None + rasterio.warp.reproject( + source=rasterio.band(src, 1), + destination=destination, + src_transform=src.transform, + src_crs=src.crs, + src_nodata=band_nodata, + dst_transform=destination_transform, + dst_crs="EPSG:4326", + dst_nodata=np.nan, + resampling=rasterio.warp.Resampling.bilinear, + ) + if np.isnan(destination).any(): + nearest = np.full((TERRAIN_TILE_SIZE, TERRAIN_TILE_SIZE), np.nan, dtype="float32") + rasterio.warp.reproject( + source=rasterio.band(src, 1), + destination=nearest, + src_transform=src.transform, + src_crs=src.crs, + src_nodata=band_nodata, + dst_transform=destination_transform, + dst_crs="EPSG:4326", + dst_nodata=np.nan, + resampling=rasterio.warp.Resampling.nearest, + ) + destination = np.where(np.isnan(destination), nearest, destination) + heights = np.nan_to_num(destination, nan=0.0).astype("float32") + + encoded = _encode_heightmap_array(heights) + payload = bytearray(encoded.tobytes(order="C")) + payload.extend(bytes([child_mask])) + payload.extend(TERRAIN_WATER_MASK_ALL_LAND) + return bytes(payload) + + +def _encode_heightmap_array(heights: Any) -> Any: + import numpy as np + + encoded = np.rint((heights + 1000.0) * 5.0) + encoded = np.clip(encoded, 0.0, float(256 * 256 - 1)) + return encoded.astype(" dict[str, str]: def analyze_elevation_dataset_job(dataset_id: str, actor_user_id: str | None) -> dict[str, str]: execute_dataset_analysis_job(dataset_id=dataset_id, actor_user_id=actor_user_id) return {"dataset_id": dataset_id, "status": "done"} + + +@celery_app.task(name="app.tasks.elevation_tasks.build_elevation_dataset_terrain_job") +def build_elevation_dataset_terrain_job(dataset_id: str, actor_user_id: str | None) -> dict[str, str]: + execute_dataset_terrain_build_job(dataset_id=dataset_id, actor_user_id=actor_user_id) + return {"dataset_id": dataset_id, "status": "done"} diff --git a/api/tests/test_async_dispatch_services.py b/api/tests/test_async_dispatch_services.py index 06b2e3c..5ab051e 100644 --- a/api/tests/test_async_dispatch_services.py +++ b/api/tests/test_async_dispatch_services.py @@ -110,6 +110,7 @@ def test_queue_dataset_analysis_reuses_existing_running_task(monkeypatch) -> Non assert first.queued is True assert first.task_id == "elev-task-1" assert first.dataset.analysis_status == "queued" + assert first.dataset.terrain_status == "not_supported" second = elevation_service.queue_dataset_analysis(session, dataset_id=dataset.id, actor=actor) assert second.queued is False @@ -119,6 +120,51 @@ def test_queue_dataset_analysis_reuses_existing_running_task(monkeypatch) -> Non session.close() +def test_queue_dataset_terrain_build_reuses_existing_running_task(monkeypatch) -> None: + testing_session = _build_sessionmaker(ElevationDataset.__table__) + session: Session = testing_session() + try: + dataset = ElevationDataset( + code="ELEV-TERRAIN-001", + name="样例地形集", + file_format="tif", + mount_code="default", + dataset_dir="/elevation/datasets/ELEV-TERRAIN-001", + file_path="/elevation/datasets/ELEV-TERRAIN-001/data.tif", + status="active", + usage_status="idle", + terrain_status="pending", + ) + session.add(dataset) + session.commit() + + actor = User( + id="actor-1", + email="actor@example.com", + username="actor", + password_hash="hashed", + status="active", + ) + + monkeypatch.setattr( + elevation_service, + "_dispatch_elevation_dataset_terrain_task", + lambda **_: SimpleNamespace(id="terrain-task-1"), + ) + + first = elevation_service.queue_dataset_terrain_build(session, dataset_id=dataset.id, actor=actor) + assert first.queued is True + assert first.task_id == "terrain-task-1" + assert first.dataset.terrain_status == "pending" + + second = elevation_service.queue_dataset_terrain_build(session, dataset_id=dataset.id, actor=actor) + assert second.queued is False + assert second.task_id == "terrain-task-1" + assert second.detail == "地形瓦片任务已存在,无需重复提交。" + finally: + session.close() + + def test_wine_create_run_queues_task_and_worker_records_failure(monkeypatch) -> None: testing_session = _build_sessionmaker(WineRun.__table__) session: Session = testing_session() diff --git a/api/tests/test_elevation_terrain_service.py b/api/tests/test_elevation_terrain_service.py new file mode 100644 index 0000000..861e492 --- /dev/null +++ b/api/tests/test_elevation_terrain_service.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.database import Base +from app.models.elevation import ElevationDataset +from app.services import elevation_service + + +def _build_session() -> Session: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(bind=engine, tables=[ElevationDataset.__table__]) + testing_session = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False) + return testing_session() + + +def test_generate_blank_heightmap_tile_has_expected_binary_layout() -> None: + tile_bytes = elevation_service._generate_heightmap_tile_bytes( + rasterio=None, + src=None, + tile_bounds={"west": -180.0, "south": -90.0, "east": 0.0, "north": 90.0}, + child_mask=5, + is_blank_root=True, + ) + + expected_height_bytes = elevation_service.TERRAIN_TILE_SIZE * elevation_service.TERRAIN_TILE_SIZE * 2 + assert len(tile_bytes) == expected_height_bytes + 1 + 1 + assert tile_bytes[expected_height_bytes] == 5 + assert tile_bytes[-1] == 0 + + +def test_build_terrain_available_ranges_and_child_mask() -> None: + bounds = {"west": 110.0, "south": 20.0, "east": 122.0, "north": 32.0} + availability = elevation_service._build_terrain_available_ranges(bounds=bounds, max_zoom=2) + + assert availability[0] == [{"startX": 0, "endX": 1, "startY": 0, "endY": 0}] + assert 1 in availability + assert 2 in availability + + tile_range = availability[1][0] + child_mask = elevation_service._build_terrain_child_mask( + availability=availability, + level=1, + x=tile_range["startX"], + y=tile_range["startY"], + max_zoom=2, + ) + assert child_mask > 0 + + +def test_get_dataset_terrain_task_status_reports_ready_detail() -> None: + session = _build_session() + try: + dataset = ElevationDataset( + code="ELEV-READY-001", + name="已就绪地形", + file_format="tif", + mount_code="default", + dataset_dir="/elevation/datasets/ELEV-READY-001", + file_path="/elevation/datasets/ELEV-READY-001/data.tif", + status="active", + usage_status="idle", + terrain_status="ready", + terrain_task_id="terrain-task-1", + terrain_url_template="/api/v1/elevation/datasets/ds/terrain/{z}/{x}/{y}.terrain", + terrain_min_zoom=0, + terrain_max_zoom=4, + ) + session.add(dataset) + session.commit() + + payload = elevation_service.get_dataset_terrain_task_status(session, dataset_id=dataset.id) + assert payload.status == "success" + assert payload.detail == "地形瓦片已就绪。" + assert payload.terrain_url_template == dataset.terrain_url_template + assert payload.terrain_max_zoom == 4 + finally: + session.close() + + +def test_get_dataset_terrain_layer_reads_driver_payload(monkeypatch) -> None: + session = _build_session() + try: + dataset = ElevationDataset( + code="ELEV-LAYER-001", + name="地形图层", + file_format="tif", + mount_code="default", + dataset_dir="/elevation/datasets/ELEV-LAYER-001", + file_path="/elevation/datasets/ELEV-LAYER-001/data.tif", + status="active", + usage_status="idle", + terrain_status="ready", + terrain_root_path="/elevation/datasets/ELEV-LAYER-001/terrain", + ) + session.add(dataset) + session.commit() + + layer_json = { + "tilejson": "2.1.0", + "format": "heightmap-1.0", + "version": "1.0.0", + "scheme": "tms", + "projection": "EPSG:4326", + "tiles": ["{z}/{x}/{y}.terrain?v=1.0.0"], + "maxzoom": 4, + "bounds": [110.0, 20.0, 122.0, 32.0], + "available": [[{"startX": 0, "endX": 1, "startY": 0, "endY": 0}]], + } + + monkeypatch.setattr(elevation_service, "_require_mount", lambda *_args, **_kwargs: SimpleNamespace()) + monkeypatch.setattr( + elevation_service, + "_build_driver_or_400", + lambda *_args, **_kwargs: SimpleNamespace( + read_file=lambda path: SimpleNamespace(path=path, content=json.dumps(layer_json).encode("utf-8")), + ), + ) + + payload = elevation_service.get_dataset_terrain_layer(session, dataset_id=dataset.id) + assert payload.format == "heightmap-1.0" + assert payload.maxzoom == 4 + assert payload.bounds == [110.0, 20.0, 122.0, 32.0] + finally: + session.close() diff --git a/memory/2026-06-09.md b/memory/2026-06-09.md index 18a816f..c846e85 100644 --- a/memory/2026-06-09.md +++ b/memory/2026-06-09.md @@ -223,3 +223,84 @@ - 风险与关注点: - 仅影响 `/admin/workers` 页面首屏展示,不涉及 Worker 数据接口、抽屉详情、权限或轮询逻辑。 + +## Work Log - 高程数据支持 DEM 地形瓦片预览与线路地形图(2026-06-09) + +- 背景: + - Issue `FL-74` 要求高程数据除已有样本预览与线路高程回填外,还要支持: + - 将 DEM 栅格数据生成可供 Cesium 直接消费的地形瓦片; + - 在高程管理页预览真实 DEM 地形; + - 在线路管理页基于选定 DEM 显示线路地形图,并暴露地形状态与必要提示。 + - 现状是: + - 后端已有高程数据集、分析任务和线路高程回填链路; + - 前端已有 Cesium 预览组件,但仍以点云/网格覆层为主,没有接入真正的 Cesium terrain provider。 + +- 本次改动: + - 后端: + - `api/app/models/elevation.py` + - 为 `ElevationDataset` 增加 `terrain_status/task/error/root/url_template/min_zoom/max_zoom/bounds/metadata` 等字段。 + - `api/app/core/database.py` + - 扩展兼容列自动补齐逻辑,为已有实例增量补地形字段,并对历史 raster/csv 数据做默认状态回填。 + - `api/app/services/elevation_service.py` + - 新增 DEM 地形瓦片构建主链路: + - 栅格边界转换到 WGS84; + - 计算 TMS 瓦片层级与范围; + - 生成 Cesium `heightmap-1.0` 瓦片二进制与 `layer.json`; + - 分析成功后自动派发地形构建任务; + - 提供地形状态查询、手动构建、layer/tile 读取服务。 + - `api/app/tasks/elevation_tasks.py` + - 新增 terrain build Celery task。 + - `api/app/api/v1/elevation.py` + - 新增接口: + - `POST /api/v1/elevation/datasets/{id}/terrain/build` + - `GET /api/v1/elevation/datasets/{id}/terrain/status` + - `GET /api/v1/elevation/datasets/{id}/terrain/layer.json` + - `GET /api/v1/elevation/datasets/{id}/terrain/{z}/{x}/{y}.terrain` + - `api/tests/test_elevation_terrain_service.py` + - 补充地形瓦片编码、状态查询与 layer 读取测试。 + - `api/tests/test_async_dispatch_services.py` + - 补充 terrain task 复用与不支持格式状态断言。 + - 前端: + - `web/src/components/elevation-preview-cesium-map.tsx` + - 支持真实 terrain provider、指针高程读数、terrain failure 回退提示和数据集范围框。 + - `web/src/components/power-line-cesium-map.tsx` + - 支持选定 DEM 地形、垂直夸张、塔位越界告警、地形加载失败回退和塔位高程来源展示。 + - `web/src/app/admin/elevation/page.tsx` + - 增加地形状态列、地形层级列、地形状态弹窗、手动生成/重试地形入口; + - 高程预览弹窗传入 dataset + access token,使预览地图优先加载真实 DEM。 + - `web/src/app/admin/power-lines/page.tsx` + - 增加 DEM 数据集选择与垂直夸张选择; + - 线路地图传入 terrain dataset + access token + exaggeration; + - 增加 DEM 状态/覆盖范围提示。 + - `web/src/lib/elevation-terrain.ts` + - 抽出地形状态判定、layer 地址解析、塔位越界统计。 + - `web/src/lib/elevation-terrain.test.js` + - 补充地形状态与越界统计测试。 + - `web/src/lib/power-line-route.ts` + - 扩展塔位高程溯源字段解析,用于地图弹窗展示。 + - `web/src/types/auth.ts` + - 补齐 terrain 相关类型。 + +- 验证: + - 基线: + - `UV_CACHE_DIR=/tmp/uv-cache uv run --with-requirements requirements.txt --with pytest --no-project env PYTHONPATH=. python -m pytest tests/test_async_dispatch_services.py tests/test_line_preparation_flow.py tests/test_tower_profile_migration.py` + - `10 passed, 1 warning` + - 修改后: + - `python3 -m py_compile app/api/v1/elevation.py app/tasks/elevation_tasks.py app/services/elevation_service.py app/schemas/elevation.py app/models/elevation.py app/core/database.py tests/test_async_dispatch_services.py tests/test_elevation_terrain_service.py` + - 通过 + - `UV_CACHE_DIR=/tmp/uv-cache uv run --with-requirements requirements.txt --with pytest --no-project env PYTHONPATH=. python -m pytest tests/test_async_dispatch_services.py tests/test_line_preparation_flow.py tests/test_tower_profile_migration.py tests/test_elevation_terrain_service.py` + - `15 passed, 1 warning` + - `node --test web/src/lib/elevation-terrain.test.js web/src/lib/power-line-route.test.js` + - `6 passed` + - `cd web && NPM_CONFIG_CACHE=/tmp/npm-cache ./node_modules/.bin/eslint ./src/components/elevation-preview-cesium-map.tsx ./src/components/power-line-cesium-map.tsx ./src/app/admin/elevation/page.tsx ./src/app/admin/power-lines/page.tsx ./src/lib/elevation-terrain.ts ./src/lib/elevation-terrain.test.js ./src/lib/power-line-route.ts --max-warnings=0` + - 通过 + - `cd web && NPM_CONFIG_CACHE=/tmp/npm-cache ./node_modules/.bin/tsc --noEmit` + - 通过 + - `cd web && NPM_CONFIG_CACHE=/tmp/npm-cache npm run build` + - 通过 + - `git diff --check` + - 通过 + +- 风险与关注点: + - 真实 Cesium 地形请求当前依赖 Bearer token 头传递,不能退化为 cookie-only 访问;后续若调整认证中间件,需要一并回归 terrain provider 链路。 + - 当前生成格式固定为 `heightmap-1.0` + `EPSG:4326` + `TMS`;若未来要切到 quantized-mesh,需要重新评估生成器和前端兼容层。 diff --git a/web/src/app/admin/elevation/page.tsx b/web/src/app/admin/elevation/page.tsx index d77459c..a3f3f7e 100644 --- a/web/src/app/admin/elevation/page.tsx +++ b/web/src/app/admin/elevation/page.tsx @@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Alert, + Button, Descriptions, Empty, Form, @@ -42,6 +43,8 @@ import type { ElevationDatasetListResponse, ElevationDatasetPreviewResponse, ElevationDatasetSummary, + ElevationDatasetTerrainBuildResponse, + ElevationDatasetTerrainTaskStatusResponse, LineListResponse, LineSummary, } from "@/types/auth"; @@ -88,6 +91,29 @@ function applyModeLabel(mode: string): string { return mode; } +function terrainStatusTagColor(status: string): string { + if (status === "ready") return "green"; + if (status === "processing") return "blue"; + if (status === "pending") return "orange"; + if (status === "failed") return "red"; + return "default"; +} + +function terrainStatusLabel(status: string): string { + if (status === "ready") return "已就绪"; + if (status === "processing") return "生成中"; + if (status === "pending") return "待生成"; + if (status === "failed") return "生成失败"; + if (status === "not_supported") return "格式不支持"; + return status || "-"; +} + +function terrainBuildActionLabel(status: string): string { + if (status === "ready") return "重建地形"; + if (status === "failed") return "重试地形"; + return "生成地形"; +} + function formatDate(value: string | null | undefined): string { if (!value) return "-"; return new Date(value).toLocaleString(); @@ -155,6 +181,8 @@ export default function AdminElevationPage() { const [analysisModalOpen, setAnalysisModalOpen] = useState(false); const [analysisDataset, setAnalysisDataset] = useState(null); + const [terrainModalOpen, setTerrainModalOpen] = useState(false); + const [terrainDataset, setTerrainDataset] = useState(null); const [datasetForm] = Form.useForm(); const [applyForm] = Form.useForm(); @@ -441,6 +469,30 @@ export default function AdminElevationPage() { }, }); + const terrainBuildMutation = useMutation({ + mutationFn: async (datasetId: string) => { + const response = await fetchWithAuth(`/api/v1/elevation/datasets/${datasetId}/terrain/build`, { + method: "POST", + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as ElevationDatasetTerrainBuildResponse; + }, + onSuccess: async (payload) => { + setError(""); + setTerrainDataset(payload.dataset); + setPreviewDataset((current) => (current?.id === payload.dataset.id ? payload.dataset : current)); + messageApi.success(payload.detail || (payload.queued ? "地形瓦片任务已提交" : "地形瓦片状态已刷新")); + await refreshElevationData(); + }, + onError: (candidate) => { + const nextError = candidate instanceof Error ? candidate.message : "提交地形瓦片任务失败"; + setError(nextError); + messageApi.error(nextError); + }, + }); + const analysisStatusQuery = useQuery({ queryKey: ["/api/v1/elevation/datasets/analysis-task", analysisDataset?.id], enabled: !!analysisDataset, @@ -455,9 +507,35 @@ export default function AdminElevationPage() { staleTime: 0, }); + const terrainStatusQuery = useQuery({ + queryKey: ["/api/v1/elevation/datasets/terrain-status", terrainDataset?.id], + enabled: !!terrainDataset && terrainModalOpen, + queryFn: async () => { + const response = await fetchWithAuth(`/api/v1/elevation/datasets/${terrainDataset?.id}/terrain/status`); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as ElevationDatasetTerrainTaskStatusResponse; + }, + refetchInterval: terrainModalOpen ? 3000 : false, + staleTime: 0, + }); + const datasets = useMemo(() => datasetsQuery.data?.items ?? [], [datasetsQuery.data?.items]); const jobs = jobsQuery.data?.items ?? []; const lines = useMemo(() => linesQuery.data?.items ?? [], [linesQuery.data?.items]); + const currentPreviewDataset = useMemo( + () => (previewDataset ? datasets.find((item) => item.id === previewDataset.id) ?? previewDataset : null), + [datasets, previewDataset], + ); + const currentAnalysisDataset = useMemo( + () => (analysisDataset ? datasets.find((item) => item.id === analysisDataset.id) ?? analysisDataset : null), + [analysisDataset, datasets], + ); + const currentTerrainDataset = useMemo( + () => (terrainDataset ? datasets.find((item) => item.id === terrainDataset.id) ?? terrainDataset : null), + [datasets, terrainDataset], + ); const lineOptions = useMemo( () => @@ -540,6 +618,21 @@ export default function AdminElevationPage() { return {value}; }, }, + { + title: "地形状态", + dataIndex: "terrain_status", + width: 120, + render: (value: string) => {terrainStatusLabel(value)}, + }, + { + title: "地形层级", + key: "terrainZoom", + width: 120, + render: (_, row) => + row.terrain_min_zoom !== null && row.terrain_max_zoom !== null + ? `${row.terrain_min_zoom} - ${row.terrain_max_zoom}` + : "-", + }, { title: "使用状态", dataIndex: "usage_status", @@ -566,7 +659,7 @@ export default function AdminElevationPage() { title: "操作", key: "actions", fixed: "right", - width: 280, + width: 380, render: (_, row) => ( 分析进度 + { + setTerrainDataset(row); + setTerrainModalOpen(true); + }} + > + 地形状态 + + { + if ( + !canManage + || terrainBuildMutation.isPending + || row.terrain_status === "not_supported" + || row.status !== "active" + ) { + return; + } + setTerrainDataset(row); + setTerrainModalOpen(true); + terrainBuildMutation.mutate(row.id); + }} + > + {terrainBuildActionLabel(row.terrain_status)} + { @@ -661,6 +785,7 @@ export default function AdminElevationPage() { datasetFilesMutation, fetchWithAuth, messageApi, + terrainBuildMutation, ], ); @@ -729,7 +854,7 @@ export default function AdminElevationPage() { ); } - const datasetTableScrollX = 2200; + const datasetTableScrollX = 2520; return (
@@ -796,7 +921,7 @@ export default function AdminElevationPage() { - {previewDataset && ( + {currentPreviewDataset && (
{previewData && previewData.warnings.length > 0 && ( )} { @@ -1030,7 +1157,7 @@ export default function AdminElevationPage() { setAnalysisDataset(null); }} > - {analysisDataset && ( + {currentAnalysisDataset && (
{analysisStatusQuery.isLoading ? (
@@ -1068,6 +1195,98 @@ export default function AdminElevationPage() { )} + { + setTerrainModalOpen(false); + setTerrainDataset(null); + }} + > + {currentTerrainDataset && ( +
+ + + + + + + + + {terrainStatusLabel(currentTerrainDataset.terrain_status)} + + + + + {terrainStatusQuery.data?.status || "-"} + + + {terrainStatusQuery.data?.task_id || currentTerrainDataset.terrain_task_id || "-"} + + {terrainStatusQuery.data?.terrain_min_zoom ?? currentTerrainDataset.terrain_min_zoom ?? "-"} + {" ~ "} + {terrainStatusQuery.data?.terrain_max_zoom ?? currentTerrainDataset.terrain_max_zoom ?? "-"} + + + {terrainStatusQuery.data?.terrain_url_template || currentTerrainDataset.terrain_url_template || "-"} + + + {formatDate(terrainStatusQuery.data?.update_date || currentTerrainDataset.update_date)} + + + {terrainStatusQuery.isLoading ? ( +
+ +
+ ) : terrainStatusQuery.error ? ( + + ) : terrainStatusQuery.data?.detail ? ( + + ) : null} +
+ )} +
+ (null); @@ -546,6 +562,8 @@ export default function AdminPowerLinesPage() { const [editingTower, setEditingTower] = useState(null); const [editingTowerProfileTower, setEditingTowerProfileTower] = useState(null); const [towerViewMode, setTowerViewMode] = useState<"table" | "map">("map"); + const [selectedTerrainDatasetId, setSelectedTerrainDatasetId] = useState(undefined); + const [terrainExaggeration, setTerrainExaggeration] = useState(1.5); const [error, setError] = useState(""); const [panelBodyHeight, setPanelBodyHeight] = useState(POWER_LINES_PANEL_MIN_HEIGHT); @@ -553,6 +571,7 @@ export default function AdminPowerLinesPage() { const canLineManage = hasPermission("line.manage"); const canTowerRead = hasPermission("tower.read") || hasPermission("tower.manage"); const canTowerManage = hasPermission("tower.manage"); + const canElevationRead = hasPermission("elevation.read") || hasPermission("elevation.manage"); const canRead = canLineRead || canTowerRead; const lineListPath = useMemo(() => { @@ -645,6 +664,18 @@ export default function AdminPowerLinesPage() { }, }); + const elevationDatasetsQuery = useQuery({ + queryKey: ["/api/v1/elevation/datasets"], + enabled: !!user && canElevationRead, + queryFn: async () => { + const response = await fetchWithAuth("/api/v1/elevation/datasets"); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as ElevationDatasetListResponse; + }, + }); + const towerProfileQuery = useQuery({ queryKey: ["tower-profile", editingTowerProfileTower?.id], enabled: !!user && !!editingTowerProfileTower && towerProfileModalOpen && canTowerRead, @@ -658,9 +689,10 @@ export default function AdminPowerLinesPage() { }); const lineError = linesQuery.error instanceof Error ? linesQuery.error.message : ""; const towerError = towersQuery.error instanceof Error ? towersQuery.error.message : ""; + const elevationDatasetError = elevationDatasetsQuery.error instanceof Error ? elevationDatasetsQuery.error.message : ""; useToastFeedback({ - errorMessage: error || lineError || towerError, + errorMessage: error || lineError || towerError || elevationDatasetError, clearError: () => setError(""), }); @@ -772,6 +804,9 @@ export default function AdminPowerLinesPage() { void refreshLines(); void refreshTowers(); }, [refreshLines, refreshTowers])); + useTopicSubscription("admin.elevation", useCallback(() => { + void queryClient.invalidateQueries({ queryKey: ["/api/v1/elevation/datasets"] }); + }, [queryClient])); useTopicSubscription("admin.tower-models", useCallback(() => { void queryClient.invalidateQueries({ queryKey: ["/api/v1/tower-models/selector"] }); }, [queryClient])); @@ -779,6 +814,44 @@ export default function AdminPowerLinesPage() { const lines = useMemo(() => linesQuery.data?.items ?? [], [linesQuery.data?.items]); const towers = useMemo(() => towersQuery.data?.items ?? [], [towersQuery.data?.items]); const towerModels = useMemo(() => towerModelOptionsQuery.data ?? [], [towerModelOptionsQuery.data]); + const elevationDatasets = useMemo(() => elevationDatasetsQuery.data?.items ?? [], [elevationDatasetsQuery.data?.items]); + const selectableTerrainDatasets = useMemo( + () => + elevationDatasets.filter( + (item) => item.status === "active" && item.terrain_status !== "not_supported", + ), + [elevationDatasets], + ); + const preferredTerrainDatasetId = useMemo( + () => + selectableTerrainDatasets.find((item) => item.terrain_status === "ready")?.id + ?? selectableTerrainDatasets[0]?.id + ?? null, + [selectableTerrainDatasets], + ); + const effectiveTerrainDatasetId = useMemo(() => { + if (selectedTerrainDatasetId === undefined) { + return preferredTerrainDatasetId; + } + if (selectedTerrainDatasetId === null) { + return null; + } + return selectableTerrainDatasets.some((item) => item.id === selectedTerrainDatasetId) + ? selectedTerrainDatasetId + : preferredTerrainDatasetId; + }, [preferredTerrainDatasetId, selectableTerrainDatasets, selectedTerrainDatasetId]); + const selectedTerrainDataset = useMemo( + () => selectableTerrainDatasets.find((item) => item.id === effectiveTerrainDatasetId) ?? null, + [effectiveTerrainDatasetId, selectableTerrainDatasets], + ); + const terrainDatasetOptions = useMemo( + () => + selectableTerrainDatasets.map((item) => ({ + value: item.id, + label: `${item.code} - ${item.name}(${terrainStatusLabel(item.terrain_status)})`, + })), + [selectableTerrainDatasets], + ); const towerModelOptions = towerModels.map((item) => ({ value: item.code, label: `${item.code} - ${item.name}` })); const effectiveSelectedLineId = useMemo(() => { if (selectedLineTouched) { @@ -1384,6 +1457,25 @@ export default function AdminPowerLinesPage() { ); const mapHeight = Math.max(POWER_LINES_MAP_MIN_HEIGHT, rightContentHeight - 32); const towerTableScrollY = Math.max(POWER_LINES_TABLE_MIN_SCROLL_Y, rightContentHeight - 54); + const terrainSelectionHint = useMemo(() => { + if (!canElevationRead) { + return "当前账号没有高程数据读取权限,线路分布图将使用椭球地表。"; + } + if (!selectedTerrainDataset) { + return "未选择 DEM 数据集,线路分布图将使用椭球地表。"; + } + if (selectedTerrainDataset.terrain_status === "ready") { + const bounds = selectedTerrainDataset.terrain_bounds; + const boundsText = bounds + ? `范围 ${bounds.west.toFixed(4)}, ${bounds.south.toFixed(4)} ~ ${bounds.east.toFixed(4)}, ${bounds.north.toFixed(4)}` + : "范围待同步"; + return `当前 DEM:${selectedTerrainDataset.code},地形已就绪,${boundsText}。`; + } + if (selectedTerrainDataset.terrain_status === "failed") { + return `当前 DEM:${selectedTerrainDataset.code},地形生成失败,将回退到椭球地表。`; + } + return `当前 DEM:${selectedTerrainDataset.code},地形状态为${terrainStatusLabel(selectedTerrainDataset.terrain_status)},就绪前将回退到椭球地表。`; + }, [canElevationRead, selectedTerrainDataset]); if (initializing || linesQuery.isLoading) { return ; } @@ -1520,6 +1612,26 @@ export default function AdminPowerLinesPage() { placeholder="按风险等级筛选" />
+
+ setTerrainExaggeration(value)} + /> +
+
diff --git a/web/src/components/elevation-preview-cesium-map.tsx b/web/src/components/elevation-preview-cesium-map.tsx index 7a07ac8..32b6d3a 100644 --- a/web/src/components/elevation-preview-cesium-map.tsx +++ b/web/src/components/elevation-preview-cesium-map.tsx @@ -3,11 +3,18 @@ import { Alert, Empty, Spin } from "antd"; import { useEffect, useMemo, useRef, useState } from "react"; +import { getApiBaseUrl } from "@/lib/api"; import { withBasePath } from "@/lib/base-path"; import { reloadOnceOnChunkError } from "@/lib/chunk-error"; -import type { ElevationDatasetPreviewCell, ElevationDatasetPreviewPoint } from "@/types/auth"; +import { getElevationTerrainLayerUrl, getElevationTerrainRenderState } from "@/lib/elevation-terrain"; +import type { ElevationDatasetPreviewCell, ElevationDatasetPreviewPoint, ElevationDatasetSummary } from "@/types/auth"; type ElevationPreviewCesiumMapProps = { + dataset?: Pick< + ElevationDatasetSummary, + "id" | "name" | "terrain_status" | "terrain_url_template" | "terrain_bounds" | "terrain_metadata" + > | null; + accessToken?: string | null; points: ElevationDatasetPreviewPoint[]; cells?: ElevationDatasetPreviewCell[]; loading?: boolean; @@ -47,6 +54,8 @@ function formatErrorMessage(candidate: unknown): string { } export function ElevationPreviewCesiumMap({ + dataset = null, + accessToken = null, points, cells = [], loading = false, @@ -56,6 +65,13 @@ export function ElevationPreviewCesiumMap({ const cesiumRef = useRef(null); const [ready, setReady] = useState(false); const [error, setError] = useState(""); + const [terrainError, setTerrainError] = useState(""); + const [pointerInfo, setPointerInfo] = useState(""); + + const terrainRenderState = useMemo( + () => (dataset ? getElevationTerrainRenderState(dataset) : "fallback"), + [dataset], + ); const safePoints = useMemo( () => @@ -133,10 +149,31 @@ export function ElevationPreviewCesiumMap({ viewer.scene.globe.showGroundAtmosphere = false; viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#0f172a"); viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#020617"); + viewer.scene.screenSpaceCameraController.enableCollisionDetection = true; const creditContainer = viewer.cesiumWidget.creditContainer as HTMLElement | null; if (creditContainer) { creditContainer.style.display = "none"; } + viewer.screenSpaceEventHandler.setInputAction((movement: { endPosition?: import("cesium").Cartesian2 }) => { + if (!movement.endPosition) { + setPointerInfo(""); + return; + } + const ray = viewer.camera.getPickRay(movement.endPosition); + const cartesian = ray + ? viewer.scene.globe.pick(ray, viewer.scene) + : viewer.camera.pickEllipsoid(movement.endPosition, viewer.scene.globe.ellipsoid); + if (!cartesian) { + setPointerInfo(""); + return; + } + const cartographic = Cesium.Cartographic.fromCartesian(cartesian); + const lon = Cesium.Math.toDegrees(cartographic.longitude); + const lat = Cesium.Math.toDegrees(cartographic.latitude); + const height = viewer.scene.globe.getHeight(cartographic) ?? cartographic.height ?? 0; + const datasetName = dataset?.name ? ` | ${dataset.name}` : ""; + setPointerInfo(`${lon.toFixed(5)}, ${lat.toFixed(5)} | ${height.toFixed(2)} m${datasetName}`); + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); viewerRef.current = viewer; cesiumRef.current = Cesium; @@ -159,8 +196,60 @@ export function ElevationPreviewCesiumMap({ viewerRef.current = null; cesiumRef.current = null; setReady(false); + setPointerInfo(""); }; - }, []); + }, [dataset?.name]); + + useEffect(() => { + let cancelled = false; + + async function updateTerrain() { + const viewer = viewerRef.current; + const Cesium = cesiumRef.current; + if (!viewer || !Cesium || !ready) { + return; + } + + setTerrainError(""); + viewer.scene.verticalExaggeration = 1.0; + viewer.scene.verticalExaggerationRelativeHeight = 0.0; + + if (terrainRenderState !== "ready" || !dataset) { + viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider(); + viewer.scene.globe.depthTestAgainstTerrain = false; + return; + } + + try { + const layerBaseUrl = `${getApiBaseUrl()}${getElevationTerrainLayerUrl(dataset)}`; + const resource = new Cesium.Resource({ + url: layerBaseUrl, + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + }); + const terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(resource, { + requestMetadata: false, + requestWaterMask: false, + requestVertexNormals: false, + }); + if (cancelled) { + return; + } + viewer.terrainProvider = terrainProvider; + viewer.scene.globe.depthTestAgainstTerrain = true; + } catch (candidate) { + if (!cancelled) { + viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider(); + viewer.scene.globe.depthTestAgainstTerrain = false; + setTerrainError(formatErrorMessage(candidate)); + } + } + } + + void updateTerrain(); + return () => { + cancelled = true; + }; + }, [accessToken, dataset, ready, terrainRenderState]); useEffect(() => { const viewer = viewerRef.current; @@ -170,11 +259,14 @@ export function ElevationPreviewCesiumMap({ } viewer.entities.removeAll(); if (safeCells.length === 0 && safePoints.length === 0) { - return; + if (!dataset?.terrain_bounds) { + return; + } } const positions: import("cesium").Cartesian3[] = []; - if (safeCells.length > 0) { + const shouldDrawFallbackOverlay = terrainRenderState !== "ready" || !!terrainError; + if (shouldDrawFallbackOverlay && safeCells.length > 0) { for (let index = 0; index < safeCells.length; index += 1) { const cell = safeCells[index]; const centerLon = (cell.min_longitude + cell.max_longitude) / 2; @@ -207,7 +299,7 @@ export function ElevationPreviewCesiumMap({ `, }); } - } else { + } else if (shouldDrawFallbackOverlay) { for (let index = 0; index < safePoints.length; index += 1) { const point = safePoints[index]; const position = Cesium.Cartesian3.fromDegrees(point.longitude, point.latitude, point.altitude_m); @@ -235,6 +327,34 @@ export function ElevationPreviewCesiumMap({ } } + const terrainBounds = dataset?.terrain_bounds; + if (terrainBounds) { + const centerLon = (terrainBounds.west + terrainBounds.east) / 2; + const centerLat = (terrainBounds.south + terrainBounds.north) / 2; + positions.push(Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0)); + viewer.entities.add({ + id: "elevation-dataset-bounds", + rectangle: { + coordinates: Cesium.Rectangle.fromDegrees( + terrainBounds.west, + terrainBounds.south, + terrainBounds.east, + terrainBounds.north, + ), + material: Cesium.Color.WHITE.withAlpha(0.02), + outline: true, + outlineColor: Cesium.Color.fromCssColorString("#f8fafc"), + height: 0, + }, + description: ` +
+
数据集:${dataset?.name ?? "-"}
+
范围:${terrainBounds.west.toFixed(4)}, ${terrainBounds.south.toFixed(4)} ~ ${terrainBounds.east.toFixed(4)}, ${terrainBounds.north.toFixed(4)}
+
+ `, + }); + } + if (positions.length > 0) { const boundingSphere = Cesium.BoundingSphere.fromPoints(positions); void viewer.camera.flyToBoundingSphere(boundingSphere, { @@ -242,7 +362,7 @@ export function ElevationPreviewCesiumMap({ offset: new Cesium.HeadingPitchRange(0, -0.6, Math.max(1200, boundingSphere.radius * 2.4)), }); } - }, [altitudeRange.max, altitudeRange.min, ready, safeCells, safePoints]); + }, [altitudeRange.max, altitudeRange.min, dataset, ready, safeCells, safePoints, terrainError, terrainRenderState]); if (error) { return ( @@ -261,9 +381,17 @@ export function ElevationPreviewCesiumMap({ return (
- 颜色由蓝到红表示高程由低到高;栅格数据优先展示地形网格色带,点位模式用于点集数据。 + 颜色由蓝到红表示高程由低到高;地形瓦片就绪时优先加载真实三维地形,失败时自动回退到现有色带/点位预览。
+ {terrainError ? ( + + ) : null}
+ {pointerInfo ?
{pointerInfo}
: null} {loading && (
diff --git a/web/src/components/power-line-cesium-map.tsx b/web/src/components/power-line-cesium-map.tsx index 139bd76..d19c339 100644 --- a/web/src/components/power-line-cesium-map.tsx +++ b/web/src/components/power-line-cesium-map.tsx @@ -4,20 +4,32 @@ import { AimOutlined, MinusOutlined, PlusOutlined } from "@ant-design/icons"; import { Alert, Button, Checkbox, Empty, Spin, Typography } from "antd"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getApiBaseUrl } from "@/lib/api"; import { withBasePath } from "@/lib/base-path"; import { reloadOnceOnChunkError } from "@/lib/chunk-error"; +import { + countLineTowersOutsideTerrainBounds, + getElevationTerrainLayerUrl, + getElevationTerrainRenderState, +} from "@/lib/elevation-terrain"; import { buildRouteSegments, collectTowerGeoPoints, type RouteSegment, type TowerGeoPoint, } from "@/lib/power-line-route"; -import type { LineTowerSummary } from "@/types/auth"; +import type { ElevationDatasetSummary, LineTowerSummary } from "@/types/auth"; type PowerLineCesiumMapProps = { lineCode?: string; lineName?: string; towers: LineTowerSummary[]; + terrainDataset?: Pick< + ElevationDatasetSummary, + "id" | "name" | "terrain_status" | "terrain_url_template" | "terrain_bounds" | "terrain_metadata" + > | null; + accessToken?: string | null; + exaggeration?: number; loading?: boolean; height?: number; }; @@ -90,6 +102,9 @@ export function PowerLineCesiumMap({ lineCode, lineName, towers, + terrainDataset = null, + accessToken = null, + exaggeration = 1, loading = false, height = DEFAULT_MAP_HEIGHT, }: PowerLineCesiumMapProps) { @@ -99,8 +114,18 @@ export function PowerLineCesiumMap({ const routeViewRef = useRef(null); const [initError, setInitError] = useState(""); const [ready, setReady] = useState(false); + const [terrainError, setTerrainError] = useState(""); + const [pointerInfo, setPointerInfo] = useState(""); const [colorByRisk, setColorByRisk] = useState(true); const [showLabels, setShowLabels] = useState(true); + const terrainRenderState = useMemo( + () => (terrainDataset ? getElevationTerrainRenderState(terrainDataset) : "fallback"), + [terrainDataset], + ); + const towersOutsideTerrainBounds = useMemo( + () => countLineTowersOutsideTerrainBounds(towers, terrainDataset?.terrain_bounds ?? null), + [terrainDataset?.terrain_bounds, towers], + ); const sortedTowers = useMemo( () => [...towers].sort((a, b) => a.seq_no - b.seq_no), @@ -188,10 +213,10 @@ export function PowerLineCesiumMap({ fullscreenButton: false, geocoder: false, homeButton: false, - infoBox: false, + infoBox: true, navigationHelpButton: false, sceneModePicker: false, - selectionIndicator: false, + selectionIndicator: true, skyBox: false, skyAtmosphere: false, timeline: false, @@ -203,10 +228,31 @@ export function PowerLineCesiumMap({ viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#0f172a"); viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#020617"); viewer.scene.screenSpaceCameraController.enableZoom = true; + viewer.scene.screenSpaceCameraController.enableCollisionDetection = true; const creditContainer = viewer.cesiumWidget.creditContainer as HTMLElement | null; if (creditContainer) { creditContainer.style.display = "none"; } + viewer.screenSpaceEventHandler.setInputAction((movement: { endPosition?: import("cesium").Cartesian2 }) => { + if (!movement.endPosition) { + setPointerInfo(""); + return; + } + const ray = viewer.camera.getPickRay(movement.endPosition); + const cartesian = ray + ? viewer.scene.globe.pick(ray, viewer.scene) + : viewer.camera.pickEllipsoid(movement.endPosition, viewer.scene.globe.ellipsoid); + if (!cartesian) { + setPointerInfo(""); + return; + } + const cartographic = Cesium.Cartographic.fromCartesian(cartesian); + const lon = Cesium.Math.toDegrees(cartographic.longitude); + const lat = Cesium.Math.toDegrees(cartographic.latitude); + const ground = viewer.scene.globe.getHeight(cartographic) ?? cartographic.height ?? 0; + const datasetName = terrainDataset?.name ? ` | ${terrainDataset.name}` : ""; + setPointerInfo(`${lon.toFixed(5)}, ${lat.toFixed(5)} | ${ground.toFixed(2)} m${datasetName}`); + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); viewerRef.current = viewer; setInitError(""); @@ -233,8 +279,64 @@ export function PowerLineCesiumMap({ cesiumRef.current = null; routeViewRef.current = null; setReady(false); + setPointerInfo(""); }; - }, []); + }, [terrainDataset?.name]); + + useEffect(() => { + let cancelled = false; + + async function updateTerrain() { + const viewer = viewerRef.current; + const Cesium = cesiumRef.current; + if (!viewer || !Cesium || !ready) { + return; + } + + setTerrainError(""); + viewer.scene.verticalExaggeration = exaggeration; + viewer.scene.verticalExaggerationRelativeHeight = 0.0; + + if (terrainRenderState !== "ready" || !terrainDataset) { + viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider(); + viewer.scene.globe.depthTestAgainstTerrain = false; + return; + } + + try { + const terrainUrl = getElevationTerrainLayerUrl(terrainDataset); + if (!terrainUrl) { + viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider(); + return; + } + const resource = new Cesium.Resource({ + url: `${getApiBaseUrl()}${terrainUrl}`, + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + }); + const terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(resource, { + requestMetadata: false, + requestWaterMask: false, + requestVertexNormals: false, + }); + if (cancelled) { + return; + } + viewer.terrainProvider = terrainProvider; + viewer.scene.globe.depthTestAgainstTerrain = true; + } catch (error) { + if (!cancelled) { + viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider(); + viewer.scene.globe.depthTestAgainstTerrain = false; + setTerrainError(formatErrorMessage(error)); + } + } + } + + void updateTerrain(); + return () => { + cancelled = true; + }; + }, [accessToken, exaggeration, ready, terrainDataset, terrainRenderState]); useEffect(() => { const viewer = viewerRef.current; @@ -307,13 +409,17 @@ export function PowerLineCesiumMap({ disableDepthTestDistance: Number.POSITIVE_INFINITY, } : undefined, - description: ` + description: `
塔号:${tower.towerNo}
序号:${tower.seqNo}
坐标:${tower.longitude.toFixed(6)}, ${tower.latitude.toFixed(6)}
海拔:${tower.altitudeM.toFixed(2)} m
+
地形:${tower.terrain ?? "-"}
风险等级:${tower.riskLevel ?? "-"}
+
高程采样:${tower.elevationPrepared ? "已回填" : "未回填"}
+
来源数据集:${tower.elevationDatasetCode ?? "-"}
+
采样方式:${tower.elevationSampleMethod ?? "-"}
`, }); @@ -330,6 +436,27 @@ export function PowerLineCesiumMap({ return (
+ {terrainDataset ? ( + + 地形底图:{terrainDataset.name},状态 {terrainDataset.terrain_status} + {terrainDataset.terrain_status === "ready" ? `,夸张 ${exaggeration.toFixed(1)}x` : ",未启用真实地形"} + {towersOutsideTerrainBounds > 0 ? `,超出范围杆塔 ${towersOutsideTerrainBounds} 个` : ""} + + ) : null} + {terrainDataset && towersOutsideTerrainBounds > 0 ? ( + + ) : null} + {terrainError ? ( + + ) : null}
setColorByRisk(event.target.checked)}> 按风险着色 @@ -384,6 +511,7 @@ export function PowerLineCesiumMap({
) : null}
+ {pointerInfo ?
{pointerInfo}
: null} {towerGeoPoints.length === 0 ? ( { + assert.equal( + getElevationTerrainRenderState({ terrain_status: "ready", terrain_url_template: "/api/v1/elevation/ds/terrain/{z}/{x}/{y}.terrain" }), + "ready", + ); + assert.equal( + getElevationTerrainRenderState({ terrain_status: "ready", terrain_url_template: null }), + "fallback", + ); +}); + +test("getElevationTerrainRenderState covers processing failed and fallback states", () => { + assert.equal(getElevationTerrainRenderState({ terrain_status: "pending", terrain_url_template: null }), "processing"); + assert.equal(getElevationTerrainRenderState({ terrain_status: "processing", terrain_url_template: null }), "processing"); + assert.equal(getElevationTerrainRenderState({ terrain_status: "failed", terrain_url_template: null }), "failed"); + assert.equal(getElevationTerrainRenderState({ terrain_status: "not_supported", terrain_url_template: null }), "fallback"); +}); + +test("countLineTowersOutsideTerrainBounds ignores towers without coordinates and counts out-of-range towers", () => { + const towers = [ + { longitude: 120.1, latitude: 30.1 }, + { longitude: 121.8, latitude: 30.2 }, + { longitude: null, latitude: 30.3 }, + { longitude: 120.3, latitude: 31.6 }, + ]; + const bounds = { west: 120.0, south: 30.0, east: 121.0, north: 31.0 }; + + assert.equal(countLineTowersOutsideTerrainBounds(towers, bounds), 2); + assert.equal(countLineTowersOutsideTerrainBounds(towers, null), 0); +}); diff --git a/web/src/lib/elevation-terrain.ts b/web/src/lib/elevation-terrain.ts new file mode 100644 index 0000000..3c7eb45 --- /dev/null +++ b/web/src/lib/elevation-terrain.ts @@ -0,0 +1,57 @@ +import type { ElevationDatasetSummary, ElevationTerrainBounds, LineTowerSummary } from "@/types/auth"; + +export type ElevationTerrainRenderState = "ready" | "processing" | "failed" | "fallback"; + +export function getElevationTerrainRenderState(dataset: Pick< + ElevationDatasetSummary, + "terrain_status" | "terrain_url_template" +>): ElevationTerrainRenderState { + if (dataset.terrain_status === "ready" && dataset.terrain_url_template) { + return "ready"; + } + if (dataset.terrain_status === "pending" || dataset.terrain_status === "processing") { + return "processing"; + } + if (dataset.terrain_status === "failed") { + return "failed"; + } + return "fallback"; +} + +export function countLineTowersOutsideTerrainBounds( + towers: Pick[], + bounds: ElevationTerrainBounds | null, +): number { + if (!bounds) { + return 0; + } + let count = 0; + for (const tower of towers) { + if (tower.longitude === null || tower.latitude === null) { + continue; + } + if ( + tower.longitude < bounds.west + || tower.longitude > bounds.east + || tower.latitude < bounds.south + || tower.latitude > bounds.north + ) { + count += 1; + } + } + return count; +} + +export function getElevationTerrainLayerUrl(dataset: Pick< + ElevationDatasetSummary, + "id" | "terrain_metadata" +> | null): string | null { + if (!dataset) { + return null; + } + const candidate = dataset.terrain_metadata?.layer_url; + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim().replace(/\/layer\.json$/, ""); + } + return `/api/v1/elevation/datasets/${dataset.id}/terrain`; +} diff --git a/web/src/lib/power-line-route.ts b/web/src/lib/power-line-route.ts index 71c5b6a..5ea7837 100644 --- a/web/src/lib/power-line-route.ts +++ b/web/src/lib/power-line-route.ts @@ -5,7 +5,9 @@ export type RouteTowerInput = { longitude: number | null; latitude: number | null; altitude_m: number | null; + terrain?: string | null; risk_level: string | null; + raw_extra_json?: Record; }; export type TowerGeoPoint = { @@ -15,7 +17,11 @@ export type TowerGeoPoint = { longitude: number; latitude: number; altitudeM: number; + terrain: string | null; riskLevel: string | null; + elevationPrepared: boolean; + elevationDatasetCode: string | null; + elevationSampleMethod: string | null; }; export type RouteSegment = { @@ -34,6 +40,13 @@ export function hasValidGeo(tower: Pick + : null; return { id: tower.id, seqNo: tower.seq_no, @@ -41,7 +54,11 @@ function toTowerGeoPoint(tower: RouteTowerInput): TowerGeoPoint { longitude: tower.longitude ?? 0, latitude: tower.latitude ?? 0, altitudeM: tower.altitude_m ?? DEFAULT_ALTITUDE_M, + terrain: tower.terrain ?? null, riskLevel: tower.risk_level, + elevationPrepared: !!elevationRecord, + elevationDatasetCode: typeof elevationRecord?.dataset_code === "string" ? elevationRecord.dataset_code : null, + elevationSampleMethod: typeof elevationRecord?.sample_method === "string" ? elevationRecord.sample_method : null, }; } diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index c58ec3b..91970b2 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -204,9 +204,17 @@ export type FileOperationResponse = { export type ElevationDatasetStatus = "active" | "disabled"; export type ElevationDatasetUsageStatus = "idle" | "in_use"; +export type ElevationDatasetTerrainStatus = "pending" | "processing" | "ready" | "failed" | "not_supported"; export type ElevationApplyMode = "fill_null_only" | "overwrite_all"; export type ElevationApplyJobStatus = "pending" | "running" | "success" | "failed"; +export type ElevationTerrainBounds = { + west: number; + south: number; + east: number; + north: number; +}; + export type ElevationDatasetSummary = { id: string; code: string; @@ -229,6 +237,15 @@ export type ElevationDatasetSummary = { analysis_error_message: string | null; analysis_started_at: string | null; analysis_finished_at: string | null; + terrain_status: ElevationDatasetTerrainStatus; + terrain_task_id: string | null; + terrain_error_message: string | null; + terrain_root_path: string | null; + terrain_url_template: string | null; + terrain_min_zoom: number | null; + terrain_max_zoom: number | null; + terrain_bounds: ElevationTerrainBounds | null; + terrain_metadata: Record | null; notes: string | null; create_date: string; create_user: string | null; @@ -249,6 +266,14 @@ export type ElevationDatasetAnalyzeResponse = { warnings: string[]; }; +export type ElevationDatasetTerrainBuildResponse = { + dataset: ElevationDatasetSummary; + task_id: string | null; + queued: boolean; + detail: string | null; + warnings: string[]; +}; + export type TowerModelSummary = { id: string; code: string; @@ -353,6 +378,18 @@ export type ElevationDatasetAnalysisTaskStatusResponse = { update_date: string | null; }; +export type ElevationDatasetTerrainTaskStatusResponse = { + dataset_id: string; + dataset_code: string; + task_id: string | null; + status: ElevationDatasetAnalysisTaskStatus; + detail: string | null; + terrain_url_template: string | null; + terrain_min_zoom: number | null; + terrain_max_zoom: number | null; + update_date: string | null; +}; + export type ElevationDatasetPreviewPoint = { longitude: number; latitude: number;