feat(map): add persisted ghost pin sandbox

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 18:29:13 +03:00
parent b91d3b913e
commit 0c3855eac5
10 changed files with 1222 additions and 13 deletions
+107 -4
View File
@@ -10,12 +10,13 @@ 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/v2"] = "missioncore.map-view/v2"
MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v3"] = "missioncore.map-view/v3"
MapInspectorSection = Literal[
"base-terrain",
"atmosphere-light",
"buildings",
"targets",
"ghost-pins",
"grid-lod",
"camera",
"tile-cache",
@@ -96,7 +97,76 @@ class MapCacheIntent(StrictMapModel):
no_overwrite: bool = True
class MapViewContent(StrictMapModel):
class MapGeoPoint(StrictMapModel):
longitude: float = Field(ge=-180.0, le=180.0)
latitude: float = Field(ge=-90.0, le=90.0)
class MapGhostPin(MapGeoPoint):
id: str = Field(
min_length=7,
max_length=86,
pattern=r"^ghost-[A-Za-z0-9-]{1,80}$",
)
label: str = Field(min_length=1, max_length=12, pattern=r"^\d{1,12}$")
heading_degrees: float = Field(ge=0.0, le=360.0)
speed_meters_per_second: float = Field(ge=0.1, le=100.0)
class MapGhostPinPresentation(StrictMapModel):
color: str = Field(default="#6fb5fb", pattern=r"^#[0-9A-Fa-f]{6}$")
opacity: float = Field(default=0.9, ge=0.0, le=1.0)
stem_height_meters: float = Field(default=1_500.0, ge=100.0, le=10_000.0)
head_size_px: float = Field(default=6.0, ge=1.0, le=32.0)
stem_width_px: float = Field(default=3.0, ge=0.25, le=12.0)
outline_color: str = Field(default="#0c0d12", pattern=r"^#[0-9A-Fa-f]{6}$")
outline_opacity: float = Field(default=0.6, ge=0.0, le=1.0)
outline_width_px: float = Field(default=1.0, ge=0.0, le=8.0)
label_mode: Literal["subject_id", "attributes", "none"] = "attributes"
label_font_weight: int = Field(default=600, ge=400, le=700, multiple_of=100)
label_size_px: float = Field(default=13.0, ge=8.0, le=32.0)
label_color: str = Field(default="#f5f5f5", pattern=r"^#[0-9A-Fa-f]{6}$")
label_background_color: str = Field(
default="#0c0d12",
pattern=r"^#[0-9A-Fa-f]{6}$",
)
label_background_opacity: float = Field(default=0.86, ge=0.0, le=1.0)
label_padding_x: float = Field(default=8.0, ge=0.0, le=32.0)
label_padding_y: float = Field(default=5.0, ge=0.0, le=32.0)
label_offset_x: float = Field(default=15.0, ge=-100.0, le=100.0)
label_offset_y: float = Field(default=10.0, ge=-100.0, le=100.0)
label_max_length: int = Field(default=48, ge=1, le=64)
label_hide_camera_height_meters: float = Field(
default=70_000.0,
ge=1_000.0,
le=500_000.0,
)
target_hide_camera_height_meters: float = Field(
default=100_000.0,
ge=1_000.0,
le=500_000.0,
)
class MapGhostPinConfiguration(StrictMapModel):
count: int = Field(default=12, ge=1, le=100)
radius_meters: float = Field(default=5_000.0, ge=100.0, le=100_000.0)
simulation_enabled: bool = False
anchor: MapGeoPoint | None = None
positions: list[MapGhostPin] = Field(default_factory=list, max_length=100)
presentation: MapGhostPinPresentation = Field(
default_factory=MapGhostPinPresentation,
)
@model_validator(mode="after")
def validate_unique_positions(self) -> MapGhostPinConfiguration:
ids = [pin.id for pin in self.positions]
if len(ids) != len(set(ids)):
raise ValueError("ghost pin ids must be unique")
return self
class MapViewContentBase(StrictMapModel):
camera: MapCamera | None = Field(
default_factory=lambda: MapCamera(
longitude=37.618423,
@@ -122,19 +192,35 @@ class MapViewContent(StrictMapModel):
layer_visibility: MapLayerVisibility = Field(default_factory=MapLayerVisibility)
@model_validator(mode="after")
def validate_selection_section(self) -> MapViewContent:
def validate_selection_section(self) -> MapViewContentBase:
if self.selected_subject_id is None and "selection" in self.inspector_open_sections:
raise ValueError("selection section requires a selected stable subject")
return self
class MapViewContent(MapViewContentBase):
ghost_pins: MapGhostPinConfiguration = Field(
default_factory=MapGhostPinConfiguration,
)
class MapViewPut(StrictMapModel):
revision: int = Field(ge=0)
view: MapViewContent
class MapViewDocument(MapViewPut):
schema_version: Literal["missioncore.map-view/v2"] = MAP_VIEW_SCHEMA_VERSION
schema_version: Literal["missioncore.map-view/v3"] = MAP_VIEW_SCHEMA_VERSION
class LegacyV2MapViewContent(MapViewContentBase):
pass
class LegacyV2MapViewDocument(StrictMapModel):
schema_version: Literal["missioncore.map-view/v2"]
revision: int = Field(ge=0)
view: LegacyV2MapViewContent
class LegacyMapVisualSettings(StrictMapModel):
@@ -210,6 +296,16 @@ def upgrade_legacy_map_view(document: LegacyMapViewDocument) -> MapViewDocument:
)
def upgrade_v2_map_view(document: LegacyV2MapViewDocument) -> MapViewDocument:
return MapViewDocument(
revision=document.revision,
view=MapViewContent(
**document.view.model_dump(mode="python"),
ghost_pins=MapGhostPinConfiguration(),
),
)
class MapViewStore:
def __init__(self, root: Path) -> None:
self.root = root.expanduser().resolve()
@@ -253,6 +349,13 @@ class MapViewStore:
and payload.get("schema_version") == "missioncore.map-view/v1"
):
return upgrade_legacy_map_view(LegacyMapViewDocument.model_validate(payload))
if (
isinstance(payload, dict)
and payload.get("schema_version") == "missioncore.map-view/v2"
):
return upgrade_v2_map_view(
LegacyV2MapViewDocument.model_validate(payload)
)
return MapViewDocument.model_validate(payload)
except (OSError, ValueError, TypeError) as exc:
raise RuntimeError("map view is corrupt") from exc