feat(map): adopt DC Default scene

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 17:55:44 +03:00
parent 5a06194962
commit b91d3b913e
9 changed files with 1299 additions and 331 deletions
@@ -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<MapInspectorSection>([
"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<typeof useMapGatewayHealth>;
}) {
return (
<Inspector
singleOpen
sections={createInspectorSections(draft, updateDraft, gatewayHealth)}
openSections={draft.view.inspectorOpenSections.filter(
(section) => 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<typeof useMapGatewayHealth>,
) {
const view = draft.view;
const settings = view.visualSettings;
return [
{
id: "base-terrain",
label: "Подложка и terrain",
description: "provider-neutral surface",
content: (
<div className="mission-map__inspector-controls">
<ControlRow label="Подложка">
<strong>Cesium World Imagery</strong>
</ControlRow>
<Checker
label="Спутниковая подложка"
checked={view.layerVisibility.imagery}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.imagery = checked;
return current;
})}
/>
<Checker
label="Terrain"
checked={view.layerVisibility.terrain}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.terrain = checked;
return current;
})}
/>
<RangeControl
label="Вертикальное преувеличение рельефа"
value={settings.terrainExaggeration * 100}
min={25}
max={300}
step={1}
formatValue={(value) => `${(value / 100).toFixed(2)}×`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.terrainExaggeration = value / 100;
return current;
})}
/>
<Checker
label="Монохромная поверхность"
checked={settings.monochromeEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.monochromeEnabled = checked;
return current;
})}
/>
<ControlRow label="Цвет монохрома">
<ColorField
label="Цвет монохромной поверхности"
value={settings.monochromeColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.monochromeColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Яркость"
value={settings.imageryBrightness}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryBrightness = value;
return current;
})}
/>
<RangeControl
label="Контраст"
value={settings.imageryContrast}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryContrast = value;
return current;
})}
/>
<RangeControl
label="Насыщенность"
value={settings.imagerySaturation}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imagerySaturation = value;
return current;
})}
/>
<RangeControl
label="Гамма"
value={settings.imageryGamma}
min={0}
max={300}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryGamma = value;
return current;
})}
/>
<RangeControl
label="Оттенок"
value={settings.imageryHue}
min={-180}
max={180}
formatValue={(value) => `${value}°`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryHue = value;
return current;
})}
/>
<RangeControl
label="Прозрачность imagery"
value={settings.imageryAlpha}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryAlpha = value;
return current;
})}
/>
<ControlRow label="Цвет планеты">
<ColorField
label="Цвет terrain без imagery"
value={settings.globeColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.globeColor = value;
return current;
})}
/>
</ControlRow>
<ControlRow label="Фон сцены">
<ColorField
label="Цвет фона сцены"
value={settings.backgroundColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.backgroundColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Высота карты"
value={view.mapHeight}
min={420}
max={1080}
step={1}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.mapHeight = value;
return current;
})}
/>
</div>
),
},
{
id: "atmosphere-light",
label: "Атмосфера и освещение",
description: "scene / color correction",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Показывать атмосферу"
checked={settings.atmosphereEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.atmosphereEnabled = checked;
return current;
})}
/>
<RangeControl
label="Атмосфера: оттенок"
value={settings.atmosphereHue}
min={-100}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.atmosphereHue = value;
return current;
})}
/>
<RangeControl
label="Атмосфера: насыщенность"
value={settings.atmosphereSaturation}
min={-100}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.atmosphereSaturation = value;
return current;
})}
/>
<RangeControl
label="Атмосфера: яркость"
value={settings.atmosphereBrightness}
min={-100}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.atmosphereBrightness = value;
return current;
})}
/>
<Checker
label="Туман"
checked={settings.fogEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.fogEnabled = checked;
return current;
})}
/>
<RangeControl
label="Плотность тумана"
value={settings.fogDensity}
min={0}
max={100}
formatValue={(value) => `${(value / 10_000).toFixed(4)}`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.fogDensity = value;
return current;
})}
/>
<Checker
label="Солнечное освещение"
checked={settings.sunEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.sunEnabled = checked;
return current;
})}
/>
<RangeControl
label="Час солнца"
value={settings.sunHour}
min={0}
max={24}
formatValue={(value) => `${value}:00 UTC`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.sunHour = value;
return current;
})}
/>
<RangeControl
label="Интенсивность света"
value={settings.sunIntensity}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.sunIntensity = value;
return current;
})}
/>
<Checker
label="Тени"
checked={settings.shadowsEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.shadowsEnabled = checked;
return current;
})}
/>
</div>
),
},
{
id: "buildings",
label: "3D здания",
description: "3D Tiles / detail",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Показывать 3D здания"
checked={view.layerVisibility.buildings}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.buildings = checked;
return current;
})}
/>
<ControlRow label="Цвет">
<ColorField
label="Цвет зданий"
value={settings.buildingsColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность"
value={Math.round(settings.buildingsOpacity * 100)}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsOpacity = value / 100;
return current;
})}
/>
<RangeControl
label="Детализация"
value={settings.buildingsMaximumScreenSpaceError}
min={4}
max={32}
formatValue={(value) => `SSE ${value}`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsMaximumScreenSpaceError = value;
return current;
})}
/>
</div>
),
},
{
id: "grid-lod",
label: "Сетка и LOD",
description: "first adapter control",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="3D-сетка"
checked={view.layerVisibility.grid}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.grid = checked;
return current;
})}
/>
<Checker
label="LOD по высоте камеры"
checked={settings.gridLodEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.gridLodEnabled = checked;
return current;
})}
/>
<RangeControl
label="Высота над поверхностью"
value={settings.gridHeightMeters}
min={0}
max={1000}
formatValue={(value) => `${value} м`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridHeightMeters = value;
return current;
})}
/>
<RangeControl
label="LOD 1: до высоты"
value={settings.gridLod1MaxHeightKm}
min={1}
max={50}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod1MaxHeightKm = value;
return current;
})}
/>
<RangeControl
label="LOD 1: шаг"
value={settings.gridLod1StepKm}
min={1}
max={10}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod1StepKm = value;
return current;
})}
/>
<RangeControl
label="LOD 2: до высоты"
value={settings.gridLod2MaxHeightKm}
min={10}
max={200}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod2MaxHeightKm = value;
return current;
})}
/>
<RangeControl
label="LOD 2: шаг"
value={settings.gridLod2StepKm}
min={1}
max={25}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod2StepKm = value;
return current;
})}
/>
<RangeControl
label="LOD 3: шаг"
value={settings.gridLod3StepKm}
min={5}
max={100}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod3StepKm = value;
return current;
})}
/>
<RangeControl
label="Радиус сетки"
value={settings.gridRadiusKm}
min={5}
max={150}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridRadiusKm = value;
return current;
})}
/>
<ControlRow label="Цвет линий">
<ColorField
label="Цвет линий сетки"
value={settings.gridColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Толщина линий"
value={settings.gridLineWidth}
min={1}
max={8}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLineWidth = value;
return current;
})}
/>
<RangeControl
label="Прозрачность сетки"
value={settings.gridOpacity}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridOpacity = value;
return current;
})}
/>
<Checker
label="Точки в пересечениях"
checked={settings.gridDotsEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.gridDotsEnabled = checked;
return current;
})}
/>
<RangeControl
label="Размер точки"
value={settings.gridDotsSize}
min={2}
max={28}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridDotsSize = value;
return current;
})}
/>
<ControlRow label="Цвет точек">
<ColorField
label="Цвет точек сетки"
value={settings.gridDotsColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridDotsColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность точек"
value={settings.gridDotsOpacity}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridDotsOpacity = value;
return current;
})}
/>
</div>
),
},
{
id: "camera",
label: "Анимация камеры",
description: "geodesic spiral survey",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Разрешить анимацию камеры"
checked={settings.cameraAnimationEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.cameraAnimationEnabled = checked;
return current;
})}
/>
<ControlRow label="Сохранённая позиция">
<StatusBadge tone={view.camera ? "success" : "neutral"}>
{view.camera ? "Есть" : "Не задана"}
</StatusBadge>
</ControlRow>
</div>
),
},
{
id: "tile-cache",
label: "TileCache",
description: "Platform Map Gateway",
content: (
<div
className="mission-map__inspector-controls"
data-health-code={gatewayHealth.code ?? undefined}
>
<Checker
label="Использовать общий TileCache"
checked={view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.enabled = checked;
return current;
})}
/>
<Checker
label="Не перезаписывать существующие тайлы"
checked={view.cacheIntent.noOverwrite}
disabled={!view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.noOverwrite = checked;
return current;
})}
/>
<CacheHealth health={gatewayHealth} />
</div>
),
},
];
}
function CacheHealth({
health,
}: {
health: ReturnType<typeof useMapGatewayHealth>;
}) {
if (health.state === "loading" || health.state === "idle") {
return (
<ControlRow label="Состояние">
<StatusBadge tone="neutral">Проверяем</StatusBadge>
</ControlRow>
);
}
if (!health.snapshot) {
return (
<ControlRow label="Состояние">
<StatusBadge tone="danger">Недоступен</StatusBadge>
</ControlRow>
);
}
const { cache } = health.snapshot;
return (
<dl className="mission-map__cache-health">
<div>
<dt>Хранилище</dt>
<dd>
<StatusBadge tone={cache.persistent ? "success" : "warning"}>
{cache.persistent ? "Постоянное" : "Временное"}
</StatusBadge>
</dd>
</div>
<div>
<dt>Объекты</dt>
<dd>{cache.entries.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Объём</dt>
<dd>
{formatBytes(cache.bytes)}
{cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`}
</dd>
</div>
<div>
<dt>Заполнение</dt>
<dd>
<StatusBadge
tone={
cache.atCapacity || health.state === "stale"
? "warning"
: "success"
}
>
{cache.atCapacity
? "Лимит"
: health.state === "stale"
? "Устарело"
: "Норма"}
</StatusBadge>
</dd>
</div>
</dl>
);
}
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}`;
}
+279 -14
View File
@@ -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,
@@ -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<MapInspectorSection>([
"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({
</WindowFooterActions>
)}
>
<Inspector
singleOpen
sections={createInspectorSections(draft, updateDraft, gatewayHealth)}
openSections={draft.view.inspectorOpenSections.filter(
(section) => 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;
});
}}
<MapSettingsInspector
draft={draft}
updateDraft={updateDraft}
gatewayHealth={gatewayHealth}
/>
</Window>
</div>
@@ -380,249 +401,6 @@ function LayerControl({
);
}
function createInspectorSections(
draft: MapViewDocument,
updateDraft: (update: (current: MapViewDocument) => MapViewDocument) => void,
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
) {
const view = draft.view;
return [
{
id: "base-terrain",
label: "Подложка и terrain",
description: "provider-neutral surface",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Спутниковая подложка"
checked={view.layerVisibility.imagery}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.imagery = checked;
return current;
})}
/>
<Checker
label="Рельеф"
checked={view.layerVisibility.terrain}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.terrain = checked;
return current;
})}
/>
<Checker
label="Монохромная подложка"
checked={view.visualSettings.monochromeEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.monochromeEnabled = checked;
return current;
})}
/>
<RangeControl
label="Высота карты"
value={view.mapHeight}
min={420}
max={1080}
step={20}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.mapHeight = value;
return current;
})}
/>
</div>
),
},
{
id: "atmosphere-light",
label: "Атмосфера и освещение",
description: "scene / color correction",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Атмосфера"
checked={view.visualSettings.atmosphereEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.atmosphereEnabled = checked;
return current;
})}
/>
<Checker
label="Освещение глобуса"
checked={view.visualSettings.lightingEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.lightingEnabled = checked;
return current;
})}
/>
</div>
),
},
{
id: "buildings",
label: "3D здания",
description: "3D Tiles / detail",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Показывать 3D-здания"
checked={view.layerVisibility.buildings}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.buildings = checked;
return current;
})}
/>
<RangeControl
label="Детализация"
value={view.visualSettings.buildingsMaximumScreenSpaceError}
min={1}
max={64}
step={1}
formatValue={(value) => `${value} SSE`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsMaximumScreenSpaceError = value;
return current;
})}
/>
</div>
),
},
{
id: "grid-lod",
label: "Сетка и LOD",
description: "first adapter control",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Планетарная сетка"
checked={view.layerVisibility.grid}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.grid = checked;
return current;
})}
/>
<RangeControl
label="Масштаб рельефа"
value={view.visualSettings.terrainExaggeration}
min={0.1}
max={8}
step={0.1}
formatValue={(value) => `${value.toFixed(1)}×`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.terrainExaggeration = value;
return current;
})}
/>
</div>
),
},
{
id: "camera",
label: "Анимация камеры",
description: "geodesic spiral survey",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Разрешить анимацию камеры"
checked={view.visualSettings.cameraAnimationEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.cameraAnimationEnabled = checked;
return current;
})}
/>
<ControlRow label="Сохранённая позиция">
<StatusBadge tone={view.camera ? "success" : "neutral"}>
{view.camera ? "Есть" : "Не задана"}
</StatusBadge>
</ControlRow>
</div>
),
},
{
id: "tile-cache",
label: "TileCache",
description: "Platform Map Gateway",
content: (
<div
className="mission-map__inspector-controls"
data-health-code={gatewayHealth.code ?? undefined}
>
<Checker
label="Использовать общий TileCache"
checked={view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.enabled = checked;
return current;
})}
/>
<Checker
label="Не перезаписывать существующие тайлы"
checked={view.cacheIntent.noOverwrite}
disabled={!view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.noOverwrite = checked;
return current;
})}
/>
<CacheHealth health={gatewayHealth} />
</div>
),
},
];
}
function CacheHealth({
health,
}: {
health: ReturnType<typeof useMapGatewayHealth>;
}) {
if (health.state === "loading" || health.state === "idle") {
return (
<ControlRow label="Состояние">
<StatusBadge tone="neutral">Проверяем</StatusBadge>
</ControlRow>
);
}
if (!health.snapshot) {
return (
<ControlRow label="Состояние">
<StatusBadge tone="danger">Недоступен</StatusBadge>
</ControlRow>
);
}
const { cache } = health.snapshot;
return (
<dl className="mission-map__cache-health">
<div>
<dt>Хранилище</dt>
<dd>
<StatusBadge tone={cache.persistent ? "success" : "warning"}>
{cache.persistent ? "Постоянное" : "Временное"}
</StatusBadge>
</dd>
</div>
<div>
<dt>Объекты</dt>
<dd>{cache.entries.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Объём</dt>
<dd>
{formatBytes(cache.bytes)}
{cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`}
</dd>
</div>
<div>
<dt>Заполнение</dt>
<dd>
<StatusBadge tone={cache.atCapacity ? "warning" : health.state === "stale" ? "warning" : "success"}>
{cache.atCapacity ? "Лимит" : health.state === "stale" ? "Устарело" : "Норма"}
</StatusBadge>
</dd>
</div>
</dl>
);
}
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}`;
}
+53 -9
View File
@@ -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);
@@ -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;
+15
View File
@@ -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.")
+128 -15
View File
@@ -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
+19 -1
View File
@@ -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()
+55 -3
View File
@@ -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