diff --git a/apps/control-station/src/components/map/MapSettingsInspector.tsx b/apps/control-station/src/components/map/MapSettingsInspector.tsx new file mode 100644 index 0000000..f3cf5f0 --- /dev/null +++ b/apps/control-station/src/components/map/MapSettingsInspector.tsx @@ -0,0 +1,681 @@ +import { + Checker, + ColorField, + ControlRow, + Inspector, + RangeControl, + StatusBadge, +} from "@nodedc/ui-react"; + +import type { + MapInspectorSection, + MapViewDocument, +} from "../../core/map/mapView"; +import type { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth"; + +export const editableMapInspectorSections = new Set([ + "base-terrain", + "atmosphere-light", + "buildings", + "grid-lod", + "camera", + "tile-cache", +]); + +export function MapSettingsInspector({ + draft, + updateDraft, + gatewayHealth, +}: { + draft: MapViewDocument; + updateDraft: ( + update: (current: MapViewDocument) => MapViewDocument, + ) => void; + gatewayHealth: ReturnType; +}) { + return ( + editableMapInspectorSections.has(section), + )} + activeId={draft.view.inspectorOpenSections[0]} + onOpenSectionsChange={(sections) => { + updateDraft((current) => { + current.view.inspectorOpenSections = sections.filter( + (section): section is MapInspectorSection => + editableMapInspectorSections.has(section as MapInspectorSection), + ).slice(0, 1); + return current; + }); + }} + /> + ); +} + +function createInspectorSections( + draft: MapViewDocument, + updateDraft: ( + update: (current: MapViewDocument) => MapViewDocument, + ) => void, + gatewayHealth: ReturnType, +) { + const view = draft.view; + const settings = view.visualSettings; + return [ + { + id: "base-terrain", + label: "Подложка и terrain", + description: "provider-neutral surface", + content: ( +
+ + Cesium World Imagery + + updateDraft((current) => { + current.view.layerVisibility.imagery = checked; + return current; + })} + /> + updateDraft((current) => { + current.view.layerVisibility.terrain = checked; + return current; + })} + /> + `${(value / 100).toFixed(2)}×`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.terrainExaggeration = value / 100; + return current; + })} + /> + updateDraft((current) => { + current.view.visualSettings.monochromeEnabled = checked; + return current; + })} + /> + + updateDraft((current) => { + current.view.visualSettings.monochromeColor = value; + return current; + })} + /> + + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.imageryBrightness = value; + return current; + })} + /> + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.imageryContrast = value; + return current; + })} + /> + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.imagerySaturation = value; + return current; + })} + /> + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.imageryGamma = value; + return current; + })} + /> + `${value}°`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.imageryHue = value; + return current; + })} + /> + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.imageryAlpha = value; + return current; + })} + /> + + updateDraft((current) => { + current.view.visualSettings.globeColor = value; + return current; + })} + /> + + + updateDraft((current) => { + current.view.visualSettings.backgroundColor = value; + return current; + })} + /> + + `${value} px`} + onChange={(value) => updateDraft((current) => { + current.view.mapHeight = value; + return current; + })} + /> +
+ ), + }, + { + id: "atmosphere-light", + label: "Атмосфера и освещение", + description: "scene / color correction", + content: ( +
+ updateDraft((current) => { + current.view.visualSettings.atmosphereEnabled = checked; + return current; + })} + /> + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.atmosphereHue = value; + return current; + })} + /> + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.atmosphereSaturation = value; + return current; + })} + /> + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.atmosphereBrightness = value; + return current; + })} + /> + updateDraft((current) => { + current.view.visualSettings.fogEnabled = checked; + return current; + })} + /> + `${(value / 10_000).toFixed(4)}`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.fogDensity = value; + return current; + })} + /> + updateDraft((current) => { + current.view.visualSettings.sunEnabled = checked; + return current; + })} + /> + `${value}:00 UTC`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.sunHour = value; + return current; + })} + /> + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.sunIntensity = value; + return current; + })} + /> + updateDraft((current) => { + current.view.visualSettings.shadowsEnabled = checked; + return current; + })} + /> +
+ ), + }, + { + id: "buildings", + label: "3D здания", + description: "3D Tiles / detail", + content: ( +
+ updateDraft((current) => { + current.view.layerVisibility.buildings = checked; + return current; + })} + /> + + updateDraft((current) => { + current.view.visualSettings.buildingsColor = value; + return current; + })} + /> + + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.buildingsOpacity = value / 100; + return current; + })} + /> + `SSE ${value}`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.buildingsMaximumScreenSpaceError = value; + return current; + })} + /> +
+ ), + }, + { + id: "grid-lod", + label: "Сетка и LOD", + description: "first adapter control", + content: ( +
+ updateDraft((current) => { + current.view.layerVisibility.grid = checked; + return current; + })} + /> + updateDraft((current) => { + current.view.visualSettings.gridLodEnabled = checked; + return current; + })} + /> + `${value} м`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.gridHeightMeters = value; + return current; + })} + /> + `${value} км`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.gridLod1MaxHeightKm = value; + return current; + })} + /> + `${value} км`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.gridLod1StepKm = value; + return current; + })} + /> + `${value} км`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.gridLod2MaxHeightKm = value; + return current; + })} + /> + `${value} км`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.gridLod2StepKm = value; + return current; + })} + /> + `${value} км`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.gridLod3StepKm = value; + return current; + })} + /> + `${value} км`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.gridRadiusKm = value; + return current; + })} + /> + + updateDraft((current) => { + current.view.visualSettings.gridColor = value; + return current; + })} + /> + + `${value} px`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.gridLineWidth = value; + return current; + })} + /> + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.gridOpacity = value; + return current; + })} + /> + updateDraft((current) => { + current.view.visualSettings.gridDotsEnabled = checked; + return current; + })} + /> + `${value} px`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.gridDotsSize = value; + return current; + })} + /> + + updateDraft((current) => { + current.view.visualSettings.gridDotsColor = value; + return current; + })} + /> + + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.gridDotsOpacity = value; + return current; + })} + /> +
+ ), + }, + { + id: "camera", + label: "Анимация камеры", + description: "geodesic spiral survey", + content: ( +
+ updateDraft((current) => { + current.view.visualSettings.cameraAnimationEnabled = checked; + return current; + })} + /> + + + {view.camera ? "Есть" : "Не задана"} + + +
+ ), + }, + { + id: "tile-cache", + label: "TileCache", + description: "Platform Map Gateway", + content: ( +
+ updateDraft((current) => { + current.view.cacheIntent.enabled = checked; + return current; + })} + /> + updateDraft((current) => { + current.view.cacheIntent.noOverwrite = checked; + return current; + })} + /> + +
+ ), + }, + ]; +} + +function CacheHealth({ + health, +}: { + health: ReturnType; +}) { + if (health.state === "loading" || health.state === "idle") { + return ( + + Проверяем + + ); + } + if (!health.snapshot) { + return ( + + Недоступен + + ); + } + const { cache } = health.snapshot; + return ( +
+
+
Хранилище
+
+ + {cache.persistent ? "Постоянное" : "Временное"} + +
+
+
+
Объекты
+
{cache.entries.toLocaleString("ru-RU")}
+
+
+
Объём
+
+ {formatBytes(cache.bytes)} + {cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`} +
+
+
+
Заполнение
+
+ + {cache.atCapacity + ? "Лимит" + : health.state === "stale" + ? "Устарело" + : "Норма"} + +
+
+
+ ); +} + +function formatBytes(value: number): string { + if (value < 1024) return `${value} Б`; + const units = ["КБ", "МБ", "ГБ", "ТБ"]; + let amount = value / 1024; + let unit = units[0]; + for (let index = 1; index < units.length && amount >= 1024; index += 1) { + amount /= 1024; + unit = units[index]; + } + return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`; +} diff --git a/apps/control-station/src/core/map/mapView.ts b/apps/control-station/src/core/map/mapView.ts index 26b3c0c..82ebedc 100644 --- a/apps/control-station/src/core/map/mapView.ts +++ b/apps/control-station/src/core/map/mapView.ts @@ -19,10 +19,44 @@ export interface MapCamera { export interface MapVisualSettings { atmosphereEnabled: boolean; - lightingEnabled: boolean; + atmosphereHue: number; + atmosphereSaturation: number; + atmosphereBrightness: number; + fogEnabled: boolean; + fogDensity: number; + sunEnabled: boolean; + sunHour: number; + sunIntensity: number; + shadowsEnabled: boolean; monochromeEnabled: boolean; + monochromeColor: string; + imageryBrightness: number; + imageryContrast: number; + imagerySaturation: number; + imageryGamma: number; + imageryHue: number; + imageryAlpha: number; + globeColor: string; + backgroundColor: string; terrainExaggeration: number; + buildingsColor: string; + buildingsOpacity: number; buildingsMaximumScreenSpaceError: number; + gridLodEnabled: boolean; + gridHeightMeters: number; + gridLod1MaxHeightKm: number; + gridLod1StepKm: number; + gridLod2MaxHeightKm: number; + gridLod2StepKm: number; + gridLod3StepKm: number; + gridRadiusKm: number; + gridLineWidth: number; + gridColor: string; + gridOpacity: number; + gridDotsEnabled: boolean; + gridDotsSize: number; + gridDotsColor: string; + gridDotsOpacity: number; cameraAnimationEnabled: boolean; } @@ -69,16 +103,57 @@ export function defaultMapViewDocument(): MapViewDocument { return { revision: 0, view: { - camera: null, + camera: { + longitude: 37.618423, + latitude: 55.751244, + height: 40_000, + heading: 0, + pitch: -51.56620156177409, + roll: 0, + }, visualSettings: { - atmosphereEnabled: true, - lightingEnabled: true, + atmosphereEnabled: false, + atmosphereHue: 0, + atmosphereSaturation: 0, + atmosphereBrightness: 0, + fogEnabled: true, + fogDensity: 2, + sunEnabled: true, + sunHour: 12, + sunIntensity: 200, + shadowsEnabled: true, monochromeEnabled: false, + monochromeColor: "#15151b", + imageryBrightness: 118, + imageryContrast: 102, + imagerySaturation: 0, + imageryGamma: 57, + imageryHue: 13, + imageryAlpha: 27, + globeColor: "#15151b", + backgroundColor: "#08090d", terrainExaggeration: 1, - buildingsMaximumScreenSpaceError: 16, + buildingsColor: "#a27aff", + buildingsOpacity: 1, + buildingsMaximumScreenSpaceError: 4, + gridLodEnabled: true, + gridHeightMeters: 500, + gridLod1MaxHeightKm: 10, + gridLod1StepKm: 1, + gridLod2MaxHeightKm: 50, + gridLod2StepKm: 5, + gridLod3StepKm: 25, + gridRadiusKm: 40, + gridLineWidth: 4, + gridColor: "#f5f5f5", + gridOpacity: 12, + gridDotsEnabled: true, + gridDotsSize: 7, + gridDotsColor: "#ffffff", + gridDotsOpacity: 58, cameraAnimationEnabled: true, }, - mapHeight: 720, + mapHeight: 694, inspectorOpenSections: [], cacheIntent: { enabled: true, @@ -89,7 +164,7 @@ export function defaultMapViewDocument(): MapViewDocument { imagery: true, terrain: true, buildings: true, - grid: false, + grid: true, targets: true, }, }, @@ -99,7 +174,7 @@ export function defaultMapViewDocument(): MapViewDocument { export function decodeMapViewDocument(value: unknown): MapViewDocument { const document = requireRecord(value, "map view"); requireExactKeys(document, ["schema_version", "revision", "view"], "map view"); - if (document.schema_version !== "missioncore.map-view/v1") { + if (document.schema_version !== "missioncore.map-view/v2") { throw new Error("Версия сохранённого состояния карты не поддерживается."); } return { @@ -124,11 +199,45 @@ export function encodeMapViewPut(document: MapViewDocument): unknown { : null, visual_settings: { atmosphere_enabled: document.view.visualSettings.atmosphereEnabled, - lighting_enabled: document.view.visualSettings.lightingEnabled, + atmosphere_hue: document.view.visualSettings.atmosphereHue, + atmosphere_saturation: document.view.visualSettings.atmosphereSaturation, + atmosphere_brightness: document.view.visualSettings.atmosphereBrightness, + fog_enabled: document.view.visualSettings.fogEnabled, + fog_density: document.view.visualSettings.fogDensity, + sun_enabled: document.view.visualSettings.sunEnabled, + sun_hour: document.view.visualSettings.sunHour, + sun_intensity: document.view.visualSettings.sunIntensity, + shadows_enabled: document.view.visualSettings.shadowsEnabled, monochrome_enabled: document.view.visualSettings.monochromeEnabled, + monochrome_color: document.view.visualSettings.monochromeColor, + imagery_brightness: document.view.visualSettings.imageryBrightness, + imagery_contrast: document.view.visualSettings.imageryContrast, + imagery_saturation: document.view.visualSettings.imagerySaturation, + imagery_gamma: document.view.visualSettings.imageryGamma, + imagery_hue: document.view.visualSettings.imageryHue, + imagery_alpha: document.view.visualSettings.imageryAlpha, + globe_color: document.view.visualSettings.globeColor, + background_color: document.view.visualSettings.backgroundColor, terrain_exaggeration: document.view.visualSettings.terrainExaggeration, + buildings_color: document.view.visualSettings.buildingsColor, + buildings_opacity: document.view.visualSettings.buildingsOpacity, buildings_maximum_screen_space_error: document.view.visualSettings.buildingsMaximumScreenSpaceError, + grid_lod_enabled: document.view.visualSettings.gridLodEnabled, + grid_height_meters: document.view.visualSettings.gridHeightMeters, + grid_lod_1_max_height_km: document.view.visualSettings.gridLod1MaxHeightKm, + grid_lod_1_step_km: document.view.visualSettings.gridLod1StepKm, + grid_lod_2_max_height_km: document.view.visualSettings.gridLod2MaxHeightKm, + grid_lod_2_step_km: document.view.visualSettings.gridLod2StepKm, + grid_lod_3_step_km: document.view.visualSettings.gridLod3StepKm, + grid_radius_km: document.view.visualSettings.gridRadiusKm, + grid_line_width: document.view.visualSettings.gridLineWidth, + grid_color: document.view.visualSettings.gridColor, + grid_opacity: document.view.visualSettings.gridOpacity, + grid_dots_enabled: document.view.visualSettings.gridDotsEnabled, + grid_dots_size: document.view.visualSettings.gridDotsSize, + grid_dots_color: document.view.visualSettings.gridDotsColor, + grid_dots_opacity: document.view.visualSettings.gridDotsOpacity, camera_animation_enabled: document.view.visualSettings.cameraAnimationEnabled, }, map_height: document.view.mapHeight, @@ -228,29 +337,175 @@ function decodeVisualSettings(value: unknown): MapVisualSettings { settings, [ "atmosphere_enabled", - "lighting_enabled", + "atmosphere_hue", + "atmosphere_saturation", + "atmosphere_brightness", + "fog_enabled", + "fog_density", + "sun_enabled", + "sun_hour", + "sun_intensity", + "shadows_enabled", "monochrome_enabled", + "monochrome_color", + "imagery_brightness", + "imagery_contrast", + "imagery_saturation", + "imagery_gamma", + "imagery_hue", + "imagery_alpha", + "globe_color", + "background_color", "terrain_exaggeration", + "buildings_color", + "buildings_opacity", "buildings_maximum_screen_space_error", + "grid_lod_enabled", + "grid_height_meters", + "grid_lod_1_max_height_km", + "grid_lod_1_step_km", + "grid_lod_2_max_height_km", + "grid_lod_2_step_km", + "grid_lod_3_step_km", + "grid_radius_km", + "grid_line_width", + "grid_color", + "grid_opacity", + "grid_dots_enabled", + "grid_dots_size", + "grid_dots_color", + "grid_dots_opacity", "camera_animation_enabled", ], "map view.view.visual_settings", ); return { atmosphereEnabled: requireBoolean(settings.atmosphere_enabled, "atmosphere_enabled"), - lightingEnabled: requireBoolean(settings.lighting_enabled, "lighting_enabled"), + atmosphereHue: requireNumber(settings.atmosphere_hue, "atmosphere_hue", -100, 100), + atmosphereSaturation: requireNumber( + settings.atmosphere_saturation, + "atmosphere_saturation", + -100, + 100, + ), + atmosphereBrightness: requireNumber( + settings.atmosphere_brightness, + "atmosphere_brightness", + -100, + 100, + ), + fogEnabled: requireBoolean(settings.fog_enabled, "fog_enabled"), + fogDensity: requireNumber(settings.fog_density, "fog_density", 0, 100), + sunEnabled: requireBoolean(settings.sun_enabled, "sun_enabled"), + sunHour: requireNumber(settings.sun_hour, "sun_hour", 0, 24), + sunIntensity: requireNumber(settings.sun_intensity, "sun_intensity", 0, 200), + shadowsEnabled: requireBoolean(settings.shadows_enabled, "shadows_enabled"), monochromeEnabled: requireBoolean(settings.monochrome_enabled, "monochrome_enabled"), + monochromeColor: requireHexColor(settings.monochrome_color, "monochrome_color"), + imageryBrightness: requireNumber( + settings.imagery_brightness, + "imagery_brightness", + 0, + 200, + ), + imageryContrast: requireNumber( + settings.imagery_contrast, + "imagery_contrast", + 0, + 200, + ), + imagerySaturation: requireNumber( + settings.imagery_saturation, + "imagery_saturation", + 0, + 200, + ), + imageryGamma: requireNumber(settings.imagery_gamma, "imagery_gamma", 0, 300), + imageryHue: requireNumber(settings.imagery_hue, "imagery_hue", -180, 180), + imageryAlpha: requireNumber(settings.imagery_alpha, "imagery_alpha", 0, 100), + globeColor: requireHexColor(settings.globe_color, "globe_color"), + backgroundColor: requireHexColor(settings.background_color, "background_color"), terrainExaggeration: requireNumber( settings.terrain_exaggeration, "terrain_exaggeration", - 0.1, - 20, + 0.25, + 3, + ), + buildingsColor: requireHexColor(settings.buildings_color, "buildings_color"), + buildingsOpacity: requireNumber( + settings.buildings_opacity, + "buildings_opacity", + 0, + 1, ), buildingsMaximumScreenSpaceError: requireNumber( settings.buildings_maximum_screen_space_error, "buildings_maximum_screen_space_error", + 4, + 32, + ), + gridLodEnabled: requireBoolean(settings.grid_lod_enabled, "grid_lod_enabled"), + gridHeightMeters: requireNumber( + settings.grid_height_meters, + "grid_height_meters", + 0, + 1000, + ), + gridLod1MaxHeightKm: requireNumber( + settings.grid_lod_1_max_height_km, + "grid_lod_1_max_height_km", 1, - 64, + 50, + ), + gridLod1StepKm: requireNumber( + settings.grid_lod_1_step_km, + "grid_lod_1_step_km", + 1, + 10, + ), + gridLod2MaxHeightKm: requireNumber( + settings.grid_lod_2_max_height_km, + "grid_lod_2_max_height_km", + 10, + 200, + ), + gridLod2StepKm: requireNumber( + settings.grid_lod_2_step_km, + "grid_lod_2_step_km", + 1, + 25, + ), + gridLod3StepKm: requireNumber( + settings.grid_lod_3_step_km, + "grid_lod_3_step_km", + 5, + 100, + ), + gridRadiusKm: requireNumber(settings.grid_radius_km, "grid_radius_km", 5, 150), + gridLineWidth: requireNumber( + settings.grid_line_width, + "grid_line_width", + 1, + 8, + ), + gridColor: requireHexColor(settings.grid_color, "grid_color"), + gridOpacity: requireNumber(settings.grid_opacity, "grid_opacity", 0, 100), + gridDotsEnabled: requireBoolean( + settings.grid_dots_enabled, + "grid_dots_enabled", + ), + gridDotsSize: requireNumber( + settings.grid_dots_size, + "grid_dots_size", + 2, + 28, + ), + gridDotsColor: requireHexColor(settings.grid_dots_color, "grid_dots_color"), + gridDotsOpacity: requireNumber( + settings.grid_dots_opacity, + "grid_dots_opacity", + 0, + 100, ), cameraAnimationEnabled: requireBoolean( settings.camera_animation_enabled, @@ -317,6 +572,16 @@ function requireBoolean(value: unknown, path: string): boolean { return value; } +function requireHexColor(value: unknown, path: string): string { + if ( + typeof value !== "string" || + !/^#[0-9a-f]{6}$/i.test(value) + ) { + throw new Error(`${path} должен быть цветом #RRGGBB.`); + } + return value.toLowerCase(); +} + function requireNumber( value: unknown, path: string, diff --git a/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx b/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx index 63ee955..39191e5 100644 --- a/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx +++ b/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx @@ -16,36 +16,24 @@ import { mapPageTemplate } from "@nodedc/page-patterns"; import { Button, Checker, - ControlRow, Icon, IconButton, - Inspector, MapGlassSurface, - RangeControl, StatusBadge, Window, WindowFooterActions, } from "@nodedc/ui-react"; +import { MapSettingsInspector } from "../../components/map/MapSettingsInspector"; import { cloneMapViewDocument, defaultMapViewDocument, - type MapInspectorSection, type MapViewDocument, } from "../../core/map/mapView"; import { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth"; import { useMapView } from "../../core/map/useMapView"; import type { WorkspaceDefinition } from "../../productModel"; -const editableInspectorSections = new Set([ - "base-terrain", - "atmosphere-light", - "buildings", - "grid-lod", - "camera", - "tile-cache", -]); - export function WorldMapWorkspace({ definition, }: { @@ -174,12 +162,57 @@ export function WorldMapWorkspace({ layers={draft.view.layerVisibility} settings={{ atmosphere_enabled: draft.view.visualSettings.atmosphereEnabled, - lighting_enabled: draft.view.visualSettings.lightingEnabled, + atmosphere_hue: draft.view.visualSettings.atmosphereHue, + atmosphere_saturation: + draft.view.visualSettings.atmosphereSaturation, + atmosphere_brightness: + draft.view.visualSettings.atmosphereBrightness, + fog_enabled: draft.view.visualSettings.fogEnabled, + fog_density: draft.view.visualSettings.fogDensity, + sun_enabled: draft.view.visualSettings.sunEnabled, + sun_hour: draft.view.visualSettings.sunHour, + sun_intensity: draft.view.visualSettings.sunIntensity, + shadows_enabled: draft.view.visualSettings.shadowsEnabled, monochrome_enabled: draft.view.visualSettings.monochromeEnabled, - terrain_exaggeration: draft.view.visualSettings.terrainExaggeration, + monochrome_color: draft.view.visualSettings.monochromeColor, + imagery_brightness: draft.view.visualSettings.imageryBrightness, + imagery_contrast: draft.view.visualSettings.imageryContrast, + imagery_saturation: draft.view.visualSettings.imagerySaturation, + imagery_gamma: draft.view.visualSettings.imageryGamma, + imagery_hue: draft.view.visualSettings.imageryHue, + imagery_alpha: draft.view.visualSettings.imageryAlpha, + globe_color: draft.view.visualSettings.globeColor, + background_color: draft.view.visualSettings.backgroundColor, + terrain_exaggeration: + draft.view.visualSettings.terrainExaggeration, + buildings_color: draft.view.visualSettings.buildingsColor, + buildings_opacity: draft.view.visualSettings.buildingsOpacity, buildings_maximum_screen_space_error: draft.view.visualSettings.buildingsMaximumScreenSpaceError, - camera_animation_enabled: draft.view.visualSettings.cameraAnimationEnabled, + grid_lod_enabled: draft.view.visualSettings.gridLodEnabled, + grid_height_meters: draft.view.visualSettings.gridHeightMeters, + grid_lod_1_max_height_km: + draft.view.visualSettings.gridLod1MaxHeightKm, + grid_lod_1_step_km: + draft.view.visualSettings.gridLod1StepKm, + grid_lod_2_max_height_km: + draft.view.visualSettings.gridLod2MaxHeightKm, + grid_lod_2_step_km: + draft.view.visualSettings.gridLod2StepKm, + grid_lod_3_step_km: + draft.view.visualSettings.gridLod3StepKm, + grid_radius_km: draft.view.visualSettings.gridRadiusKm, + grid_line_width: draft.view.visualSettings.gridLineWidth, + grid_color: draft.view.visualSettings.gridColor, + grid_opacity: draft.view.visualSettings.gridOpacity, + grid_dots_enabled: + draft.view.visualSettings.gridDotsEnabled, + grid_dots_size: draft.view.visualSettings.gridDotsSize, + grid_dots_color: draft.view.visualSettings.gridDotsColor, + grid_dots_opacity: + draft.view.visualSettings.gridDotsOpacity, + camera_animation_enabled: + draft.view.visualSettings.cameraAnimationEnabled, }} cacheIntent={{ enabled: draft.view.cacheIntent.enabled, @@ -338,22 +371,10 @@ export function WorldMapWorkspace({ )} > - editableInspectorSections.has(section), - )} - activeId={draft.view.inspectorOpenSections[0]} - onOpenSectionsChange={(sections) => { - updateDraft((current) => { - current.view.inspectorOpenSections = sections.filter( - (section): section is MapInspectorSection => - editableInspectorSections.has(section as MapInspectorSection), - ).slice(0, 1); - return current; - }); - }} + @@ -380,249 +401,6 @@ function LayerControl({ ); } -function createInspectorSections( - draft: MapViewDocument, - updateDraft: (update: (current: MapViewDocument) => MapViewDocument) => void, - gatewayHealth: ReturnType, -) { - const view = draft.view; - return [ - { - id: "base-terrain", - label: "Подложка и terrain", - description: "provider-neutral surface", - content: ( -
- updateDraft((current) => { - current.view.layerVisibility.imagery = checked; - return current; - })} - /> - updateDraft((current) => { - current.view.layerVisibility.terrain = checked; - return current; - })} - /> - updateDraft((current) => { - current.view.visualSettings.monochromeEnabled = checked; - return current; - })} - /> - `${value} px`} - onChange={(value) => updateDraft((current) => { - current.view.mapHeight = value; - return current; - })} - /> -
- ), - }, - { - id: "atmosphere-light", - label: "Атмосфера и освещение", - description: "scene / color correction", - content: ( -
- updateDraft((current) => { - current.view.visualSettings.atmosphereEnabled = checked; - return current; - })} - /> - updateDraft((current) => { - current.view.visualSettings.lightingEnabled = checked; - return current; - })} - /> -
- ), - }, - { - id: "buildings", - label: "3D здания", - description: "3D Tiles / detail", - content: ( -
- updateDraft((current) => { - current.view.layerVisibility.buildings = checked; - return current; - })} - /> - `${value} SSE`} - onChange={(value) => updateDraft((current) => { - current.view.visualSettings.buildingsMaximumScreenSpaceError = value; - return current; - })} - /> -
- ), - }, - { - id: "grid-lod", - label: "Сетка и LOD", - description: "first adapter control", - content: ( -
- updateDraft((current) => { - current.view.layerVisibility.grid = checked; - return current; - })} - /> - `${value.toFixed(1)}×`} - onChange={(value) => updateDraft((current) => { - current.view.visualSettings.terrainExaggeration = value; - return current; - })} - /> -
- ), - }, - { - id: "camera", - label: "Анимация камеры", - description: "geodesic spiral survey", - content: ( -
- updateDraft((current) => { - current.view.visualSettings.cameraAnimationEnabled = checked; - return current; - })} - /> - - - {view.camera ? "Есть" : "Не задана"} - - -
- ), - }, - { - id: "tile-cache", - label: "TileCache", - description: "Platform Map Gateway", - content: ( -
- updateDraft((current) => { - current.view.cacheIntent.enabled = checked; - return current; - })} - /> - updateDraft((current) => { - current.view.cacheIntent.noOverwrite = checked; - return current; - })} - /> - -
- ), - }, - ]; -} - -function CacheHealth({ - health, -}: { - health: ReturnType; -}) { - if (health.state === "loading" || health.state === "idle") { - return ( - - Проверяем - - ); - } - if (!health.snapshot) { - return ( - - Недоступен - - ); - } - const { cache } = health.snapshot; - return ( -
-
-
Хранилище
-
- - {cache.persistent ? "Постоянное" : "Временное"} - -
-
-
-
Объекты
-
{cache.entries.toLocaleString("ru-RU")}
-
-
-
Объём
-
- {formatBytes(cache.bytes)} - {cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`} -
-
-
-
Заполнение
-
- - {cache.atCapacity ? "Лимит" : health.state === "stale" ? "Устарело" : "Норма"} - -
-
-
- ); -} - function runtimePresentation(state: MapRuntimeState): { label: string; tone: "neutral" | "success" | "warning" | "danger"; @@ -650,15 +428,3 @@ function providerPresentation( if (provider.phase === "error") return { label: "Недоступен", tone: "danger" }; return { label: "Загрузка", tone: "neutral" }; } - -function formatBytes(value: number): string { - if (value < 1024) return `${value} Б`; - const units = ["КБ", "МБ", "ГБ", "ТБ"]; - let amount = value / 1024; - let unit = units[0]; - for (let index = 1; index < units.length && amount >= 1024; index += 1) { - amount /= 1024; - unit = units[index]; - } - return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`; -} diff --git a/apps/control-station/test/mapView.test.mjs b/apps/control-station/test/mapView.test.mjs index d1c9c52..3660782 100644 --- a/apps/control-station/test/mapView.test.mjs +++ b/apps/control-station/test/mapView.test.mjs @@ -22,19 +22,60 @@ after(async () => { function serverDocument(overrides = {}) { return { - schema_version: "missioncore.map-view/v1", + schema_version: "missioncore.map-view/v2", revision: 0, view: { - camera: null, + camera: { + longitude: 37.618423, + latitude: 55.751244, + height: 40000, + heading: 0, + pitch: -51.56620156177409, + roll: 0, + }, visual_settings: { - atmosphere_enabled: true, - lighting_enabled: true, + atmosphere_enabled: false, + atmosphere_hue: 0, + atmosphere_saturation: 0, + atmosphere_brightness: 0, + fog_enabled: true, + fog_density: 2, + sun_enabled: true, + sun_hour: 12, + sun_intensity: 200, + shadows_enabled: true, monochrome_enabled: false, + monochrome_color: "#15151b", + imagery_brightness: 118, + imagery_contrast: 102, + imagery_saturation: 0, + imagery_gamma: 57, + imagery_hue: 13, + imagery_alpha: 27, + globe_color: "#15151b", + background_color: "#08090d", terrain_exaggeration: 1, - buildings_maximum_screen_space_error: 16, + buildings_color: "#a27aff", + buildings_opacity: 1, + buildings_maximum_screen_space_error: 4, + grid_lod_enabled: true, + grid_height_meters: 500, + grid_lod_1_max_height_km: 10, + grid_lod_1_step_km: 1, + grid_lod_2_max_height_km: 50, + grid_lod_2_step_km: 5, + grid_lod_3_step_km: 25, + grid_radius_km: 40, + grid_line_width: 4, + grid_color: "#f5f5f5", + grid_opacity: 12, + grid_dots_enabled: true, + grid_dots_size: 7, + grid_dots_color: "#ffffff", + grid_dots_opacity: 58, camera_animation_enabled: true, }, - map_height: 720, + map_height: 694, inspector_open_sections: [], cache_intent: { enabled: true, @@ -45,7 +86,7 @@ function serverDocument(overrides = {}) { imagery: true, terrain: true, buildings: true, - grid: false, + grid: true, targets: true, }, }, @@ -53,9 +94,12 @@ function serverDocument(overrides = {}) { }; } -test("map view starts without a fabricated global camera or subject", () => { +test("map view starts from the canonical Foundry camera without a fabricated subject", () => { const document = mapView.defaultMapViewDocument(); - assert.equal(document.view.camera, null); + assert.equal(document.view.camera.longitude, 37.618423); + assert.equal(document.view.camera.latitude, 55.751244); + assert.equal(document.view.visualSettings.imagerySaturation, 0); + assert.equal(document.view.visualSettings.gridDotsOpacity, 58); assert.equal(document.view.selectedSubjectId, null); assert.equal(document.view.cacheIntent.enabled, true); assert.equal(document.view.cacheIntent.noOverwrite, true); diff --git a/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md b/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md index 2bd0eda..d8d426a 100644 --- a/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md +++ b/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md @@ -194,8 +194,22 @@ the existing Gateway is reachable and reports a configured state. ## Map state contract -Add a versioned Mission Core map document outside operator environment media, -for example `missioncore.map-view/v1`, with optimistic revision: +Mission Core stores a versioned map document outside operator environment +media. The implemented contract is `missioncore.map-view/v2`, with optimistic +revision: + +- its default camera and complete visual state are the canonical Foundry + `DC Default` profile; +- black-and-white presentation is produced by zero imagery saturation while + imagery remains active; +- atmosphere, fog, sun, shadows, terrain, buildings and the elevated LOD grid + are persisted as provider-neutral values; +- saved `missioncore.map-view/v1` documents are upgraded in memory and become + v2 on the next explicit save; +- provider attribution remains registered in the renderer. The local + experiment may hide only the visual credit overlay through + `MISSIONCORE_MAP_SANDBOX_HIDE_CREDITS=1`; production defaults to visible + credits and must not enable that sandbox flag. - camera: longitude, latitude, height, heading, pitch and roll; - provider-neutral visual settings compatible with Map Page; diff --git a/src/k1link/web/map_api.py b/src/k1link/web/map_api.py index 1a48f24..a837bab 100644 --- a/src/k1link/web/map_api.py +++ b/src/k1link/web/map_api.py @@ -13,6 +13,7 @@ from fastapi import APIRouter, HTTPException, Path, Query, Request from fastapi.responses import JSONResponse, Response, StreamingResponse MAP_GATEWAY_URL_ENV: Final = "MISSIONCORE_MAP_GATEWAY_INTERNAL_URL" +MAP_SANDBOX_HIDE_CREDITS_ENV: Final = "MISSIONCORE_MAP_SANDBOX_HIDE_CREDITS" MAP_SCHEMA_VERSION: Final = "missioncore.map-gateway/v1" MAP_RUNTIME_SCHEMA_VERSION: Final = "missioncore.map-runtime/v1" MAP_PAGE_VERSION: Final = "0.1.0" @@ -149,6 +150,11 @@ class MapGatewayProxy: "terrain": 1, "buildings": 96188, }, + "sandbox": { + "hide_credit_overlay": _environment_flag( + MAP_SANDBOX_HIDE_CREDITS_ENV + ) + }, }, headers={"Cache-Control": "no-store"}, ) @@ -309,6 +315,15 @@ def _validate_internal_url(raw_url: str) -> str: return raw_url.rstrip("/") +def _environment_flag(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + def _validate_cache_target(target_url: str) -> None: if len(target_url) > MAX_CACHE_TARGET_LENGTH: raise HTTPException(status_code=422, detail="Map resource URL is too long.") diff --git a/src/k1link/web/map_view_api.py b/src/k1link/web/map_view_api.py index c553bef..de50e32 100644 --- a/src/k1link/web/map_view_api.py +++ b/src/k1link/web/map_view_api.py @@ -10,7 +10,7 @@ from typing import Literal from fastapi import APIRouter, HTTPException from pydantic import BaseModel, ConfigDict, Field, model_validator -MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v1"] = "missioncore.map-view/v1" +MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v2"] = "missioncore.map-view/v2" MapInspectorSection = Literal[ "base-terrain", "atmosphere-light", @@ -37,15 +37,49 @@ class MapCamera(StrictMapModel): class MapVisualSettings(StrictMapModel): - atmosphere_enabled: bool = True - lighting_enabled: bool = True + atmosphere_enabled: bool = False + atmosphere_hue: float = Field(default=0.0, ge=-100.0, le=100.0) + atmosphere_saturation: float = Field(default=0.0, ge=-100.0, le=100.0) + atmosphere_brightness: float = Field(default=0.0, ge=-100.0, le=100.0) + fog_enabled: bool = True + fog_density: float = Field(default=2.0, ge=0.0, le=100.0) + sun_enabled: bool = True + sun_hour: float = Field(default=12.0, ge=0.0, le=24.0) + sun_intensity: float = Field(default=200.0, ge=0.0, le=200.0) + shadows_enabled: bool = True monochrome_enabled: bool = False - terrain_exaggeration: float = Field(default=1.0, ge=0.1, le=20.0) + monochrome_color: str = Field(default="#15151b", pattern=r"^#[0-9A-Fa-f]{6}$") + imagery_brightness: float = Field(default=118.0, ge=0.0, le=200.0) + imagery_contrast: float = Field(default=102.0, ge=0.0, le=200.0) + imagery_saturation: float = Field(default=0.0, ge=0.0, le=200.0) + imagery_gamma: float = Field(default=57.0, ge=0.0, le=300.0) + imagery_hue: float = Field(default=13.0, ge=-180.0, le=180.0) + imagery_alpha: float = Field(default=27.0, ge=0.0, le=100.0) + globe_color: str = Field(default="#15151b", pattern=r"^#[0-9A-Fa-f]{6}$") + background_color: str = Field(default="#08090d", pattern=r"^#[0-9A-Fa-f]{6}$") + terrain_exaggeration: float = Field(default=1.0, ge=0.25, le=3.0) + buildings_color: str = Field(default="#a27aff", pattern=r"^#[0-9A-Fa-f]{6}$") + buildings_opacity: float = Field(default=1.0, ge=0.0, le=1.0) buildings_maximum_screen_space_error: float = Field( - default=16.0, - ge=1.0, - le=64.0, + default=4.0, + ge=4.0, + le=32.0, ) + grid_lod_enabled: bool = True + grid_height_meters: float = Field(default=500.0, ge=0.0, le=1000.0) + grid_lod_1_max_height_km: float = Field(default=10.0, ge=1.0, le=50.0) + grid_lod_1_step_km: float = Field(default=1.0, ge=1.0, le=10.0) + grid_lod_2_max_height_km: float = Field(default=50.0, ge=10.0, le=200.0) + grid_lod_2_step_km: float = Field(default=5.0, ge=1.0, le=25.0) + grid_lod_3_step_km: float = Field(default=25.0, ge=5.0, le=100.0) + grid_radius_km: float = Field(default=40.0, ge=5.0, le=150.0) + grid_line_width: float = Field(default=4.0, ge=1.0, le=8.0) + grid_color: str = Field(default="#f5f5f5", pattern=r"^#[0-9A-Fa-f]{6}$") + grid_opacity: float = Field(default=12.0, ge=0.0, le=100.0) + grid_dots_enabled: bool = True + grid_dots_size: float = Field(default=7.0, ge=2.0, le=28.0) + grid_dots_color: str = Field(default="#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$") + grid_dots_opacity: float = Field(default=58.0, ge=0.0, le=100.0) camera_animation_enabled: bool = True @@ -53,7 +87,7 @@ class MapLayerVisibility(StrictMapModel): imagery: bool = True terrain: bool = True buildings: bool = True - grid: bool = False + grid: bool = True targets: bool = True @@ -63,9 +97,18 @@ class MapCacheIntent(StrictMapModel): class MapViewContent(StrictMapModel): - camera: MapCamera | None = None + camera: MapCamera | None = Field( + default_factory=lambda: MapCamera( + longitude=37.618423, + latitude=55.751244, + height=40_000.0, + heading=0.0, + pitch=-51.56620156177409, + roll=0.0, + ) + ) visual_settings: MapVisualSettings = Field(default_factory=MapVisualSettings) - map_height: int = Field(default=720, ge=420, le=2160) + map_height: int = Field(default=694, ge=420, le=2160) inspector_open_sections: list[MapInspectorSection] = Field( default_factory=list, max_length=1, @@ -91,7 +134,43 @@ class MapViewPut(StrictMapModel): class MapViewDocument(MapViewPut): - schema_version: Literal["missioncore.map-view/v1"] = MAP_VIEW_SCHEMA_VERSION + schema_version: Literal["missioncore.map-view/v2"] = MAP_VIEW_SCHEMA_VERSION + + +class LegacyMapVisualSettings(StrictMapModel): + atmosphere_enabled: bool = True + lighting_enabled: bool = True + monochrome_enabled: bool = False + terrain_exaggeration: float = Field(default=1.0, ge=0.1, le=20.0) + buildings_maximum_screen_space_error: float = Field( + default=16.0, + ge=1.0, + le=64.0, + ) + camera_animation_enabled: bool = True + + +class LegacyMapViewContent(StrictMapModel): + camera: MapCamera | None = None + visual_settings: LegacyMapVisualSettings = Field(default_factory=LegacyMapVisualSettings) + map_height: int = Field(default=720, ge=420, le=2160) + inspector_open_sections: list[MapInspectorSection] = Field( + default_factory=list, + max_length=1, + ) + cache_intent: MapCacheIntent = Field(default_factory=MapCacheIntent) + selected_subject_id: str | None = Field( + default=None, + max_length=160, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$", + ) + layer_visibility: MapLayerVisibility = Field(default_factory=MapLayerVisibility) + + +class LegacyMapViewDocument(StrictMapModel): + schema_version: Literal["missioncore.map-view/v1"] + revision: int = Field(ge=0) + view: LegacyMapViewContent def default_map_view() -> MapViewDocument: @@ -101,6 +180,36 @@ def default_map_view() -> MapViewDocument: ) +def upgrade_legacy_map_view(document: LegacyMapViewDocument) -> MapViewDocument: + legacy_settings = document.view.visual_settings + settings = MapVisualSettings( + atmosphere_enabled=legacy_settings.atmosphere_enabled, + sun_enabled=legacy_settings.lighting_enabled, + monochrome_enabled=legacy_settings.monochrome_enabled, + terrain_exaggeration=min( + 3.0, + max(0.25, legacy_settings.terrain_exaggeration), + ), + buildings_maximum_screen_space_error=min( + 32.0, + max(4.0, legacy_settings.buildings_maximum_screen_space_error), + ), + camera_animation_enabled=legacy_settings.camera_animation_enabled, + ) + return MapViewDocument( + revision=document.revision, + view=MapViewContent( + camera=document.view.camera, + visual_settings=settings, + map_height=document.view.map_height, + inspector_open_sections=document.view.inspector_open_sections, + cache_intent=document.view.cache_intent, + selected_subject_id=document.view.selected_subject_id, + layer_visibility=document.view.layer_visibility, + ), + ) + + class MapViewStore: def __init__(self, root: Path) -> None: self.root = root.expanduser().resolve() @@ -138,10 +247,14 @@ class MapViewStore: if not self.document_path.is_file(): return default_map_view() try: - return MapViewDocument.model_validate_json( - self.document_path.read_text(encoding="utf-8") - ) - except (OSError, ValueError) as exc: + payload = json.loads(self.document_path.read_text(encoding="utf-8")) + if ( + isinstance(payload, dict) + and payload.get("schema_version") == "missioncore.map-view/v1" + ): + return upgrade_legacy_map_view(LegacyMapViewDocument.model_validate(payload)) + return MapViewDocument.model_validate(payload) + except (OSError, ValueError, TypeError) as exc: raise RuntimeError("map view is corrupt") from exc diff --git a/tests/test_map_api.py b/tests/test_map_api.py index 09e187d..649739a 100644 --- a/tests/test_map_api.py +++ b/tests/test_map_api.py @@ -13,6 +13,7 @@ from fastapi.routing import APIRoute from k1link.web.map_api import ( MAP_GATEWAY_URL_ENV, + MAP_SANDBOX_HIDE_CREDITS_ENV, MapGatewayConfiguration, MapGatewayProxy, build_map_router, @@ -108,7 +109,10 @@ def test_configuration_is_fail_closed_and_rejects_non_origin_values( MapGatewayConfiguration.from_environment() -def test_runtime_config_exposes_only_same_origin_contract_and_hides_internal_origin() -> None: +def test_runtime_config_exposes_only_same_origin_contract_and_hides_internal_origin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(MAP_SANDBOX_HIDE_CREDITS_ENV, raising=False) service, requests = _service( lambda request: httpx.Response( 200, @@ -133,11 +137,25 @@ def test_runtime_config_exposes_only_same_origin_contract_and_hides_internal_ori "terrain": 1, "buildings": 96188, } + assert document["sandbox"] == {"hide_credit_overlay": False} assert response.headers["cache-control"] == "no-store" assert not requests assert "map-gateway.internal" not in _response_body(response).decode() +def test_runtime_config_hides_credits_only_with_explicit_sandbox_flag( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, _ = _service(lambda request: httpx.Response(200)) + monkeypatch.setenv(MAP_SANDBOX_HIDE_CREDITS_ENV, "true") + + response = service.runtime_configuration() + + assert isinstance(response, JSONResponse) + document = json.loads(_response_body(response)) + assert document["sandbox"] == {"hide_credit_overlay": True} + + def test_runtime_config_is_fail_closed_when_gateway_is_not_configured() -> None: service = MapGatewayProxy(MapGatewayConfiguration(None)) response = service.runtime_configuration() diff --git a/tests/test_map_view_api.py b/tests/test_map_view_api.py index e10388d..ef456a4 100644 --- a/tests/test_map_view_api.py +++ b/tests/test_map_view_api.py @@ -15,15 +15,23 @@ from k1link.web.map_view_api import ( ) -def test_default_map_view_is_honestly_unlocated_and_cache_safe() -> None: +def test_default_map_view_matches_the_canonical_foundry_scene_and_cache_policy() -> None: document = default_map_view() - assert document.schema_version == "missioncore.map-view/v1" + assert document.schema_version == "missioncore.map-view/v2" assert document.revision == 0 - assert document.view.camera is None + assert document.view.camera is not None + assert document.view.camera.longitude == pytest.approx(37.618423) + assert document.view.camera.latitude == pytest.approx(55.751244) + assert document.view.camera.height == pytest.approx(40_000.0) assert document.view.selected_subject_id is None assert document.view.layer_visibility.imagery is True + assert document.view.layer_visibility.grid is True assert document.view.layer_visibility.targets is True + assert document.view.visual_settings.imagery_saturation == 0 + assert document.view.visual_settings.imagery_alpha == 27 + assert document.view.visual_settings.imagery_gamma == 57 + assert document.view.visual_settings.background_color == "#08090d" assert document.view.cache_intent.enabled is True assert document.view.cache_intent.no_overwrite is True @@ -98,3 +106,47 @@ def test_persisted_document_contains_no_runtime_or_credential_material( assert "gateway" not in serialized assert "provider" not in serialized assert "cesium" not in serialized + + +def test_v1_map_view_is_upgraded_without_rewriting_the_source_file( + tmp_path: Path, +) -> None: + store = MapViewStore(tmp_path / "map-view") + store.root.mkdir(parents=True) + legacy = { + "schema_version": "missioncore.map-view/v1", + "revision": 7, + "view": { + "camera": None, + "visual_settings": { + "atmosphere_enabled": True, + "lighting_enabled": False, + "monochrome_enabled": True, + "terrain_exaggeration": 2, + "buildings_maximum_screen_space_error": 12, + "camera_animation_enabled": False, + }, + "map_height": 720, + "inspector_open_sections": [], + "cache_intent": {"enabled": True, "no_overwrite": True}, + "selected_subject_id": None, + "layer_visibility": { + "imagery": True, + "terrain": True, + "buildings": True, + "grid": False, + "targets": True, + }, + }, + } + store.document_path.write_text(json.dumps(legacy), encoding="utf-8") + + upgraded = store.read() + + assert upgraded.schema_version == "missioncore.map-view/v2" + assert upgraded.revision == 7 + assert upgraded.view.camera is None + assert upgraded.view.visual_settings.atmosphere_enabled is True + assert upgraded.view.visual_settings.sun_enabled is False + assert upgraded.view.visual_settings.monochrome_enabled is True + assert json.loads(store.document_path.read_text(encoding="utf-8")) == legacy