diff --git a/apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx b/apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx index ccece44a7..1eb61c9d4 100644 --- a/apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx @@ -9,6 +9,7 @@ import { KIND_I18N_KEY } from "./add-data/constants"; import { ArcGISSource } from "./add-data/sources/ArcGISSource"; import { CadSource } from "./add-data/sources/CadSource"; import { CesiumIonSource } from "./add-data/sources/CesiumIonSource"; +import { CzmlSource } from "./add-data/sources/CzmlSource"; import { DeckVizSource } from "./add-data/sources/DeckVizSource"; import { DelimitedTextSource } from "./add-data/sources/DelimitedTextSource"; import { GdbSource } from "./add-data/sources/GdbSource"; @@ -84,6 +85,8 @@ function renderSource( return ; case "cesium-ion": return ; + case "czml": + return ; case "wms": return ; case "csw": diff --git a/apps/geolibre-desktop/src/components/layout/add-data/constants.ts b/apps/geolibre-desktop/src/components/layout/add-data/constants.ts index c94f03036..f39d4b4e8 100644 --- a/apps/geolibre-desktop/src/components/layout/add-data/constants.ts +++ b/apps/geolibre-desktop/src/components/layout/add-data/constants.ts @@ -32,7 +32,8 @@ export type KindI18nKey = | "iceberg" | "deckglViz" | "video" - | "cesiumIon"; + | "cesiumIon" + | "czml"; /** * Maps each Add Data kind to its `addData.kind.` i18n segment. The dialog @@ -61,6 +62,7 @@ export const KIND_I18N_KEY: Record = { "deckgl-viz": "deckglViz", video: "video", "cesium-ion": "cesiumIon", + czml: "czml", }; export const DEFAULT_XYZ_URL = diff --git a/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx b/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx new file mode 100644 index 000000000..1ef17eafc --- /dev/null +++ b/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx @@ -0,0 +1,181 @@ +import { CZML_QUICK_PICKS, createCzmlLayer, parseCzml } from "@geolibre/core"; +import { Button, Input, Label, Select } from "@geolibre/ui"; +import { FileUp } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { openLocalDataFileWithFallback } from "../../../../lib/tauri-io"; +import { errorMessage, layerNameFromPath } from "../helpers"; +import { AddDataSourceForm, useAddDataSource } from "../shared"; + +/** Mode for supplying CZML data: remote URL endpoint or local file. */ +export type CzmlMode = "url" | "file"; + +/** + * Add a CZML (Cesium Language) layer for dynamic 3D scenes (issue #2290). + * + * CZML describes time-dynamic 3D geospatial scenes, satellite orbits, vehicle + * paths, and sensory models on the Cesium globe with synchronized clock playback. + * + * @param props Component properties. + * @param props.initialUrl Optional prefilled URL from deep links. + */ +export function CzmlSource({ initialUrl }: { initialUrl?: string }) { + const { t } = useTranslation(); + const [defaultName] = useState(() => t("addData.czml.defaultName")); + const source = useAddDataSource(defaultName); + const [czmlMode, setCzmlMode] = useState("url"); + const [czmlUrl, setCzmlUrl] = useState(initialUrl ?? ""); + const [selectedFile, setSelectedFile] = useState<{ + path: string; + text: string; + } | null>(null); + + const handleModeChange = (mode: CzmlMode) => { + setCzmlMode(mode); + setSelectedFile(null); + }; + + const handleChooseFile = async () => { + source.setError(null); + try { + const result = await openLocalDataFileWithFallback({ + filters: [ + { + name: "CZML / JSON", + extensions: ["czml", "json"], + }, + ], + accept: ".czml,.json", + readText: true, + }); + if (!result) return; + if (!result.text) throw new Error(t("addData.czml.errorFileMissing")); + setSelectedFile({ + path: result.path, + text: result.text, + }); + source.setLayerName((current) => + current.trim() && current !== defaultName + ? current + : layerNameFromPath(result.path, defaultName), + ); + } catch (err) { + source.setError(errorMessage(err, t("addData.czml.readError"))); + } + }; + + const handleSubmit = source.runSubmit(() => { + const name = source.layerName.trim() || defaultName; + + if (czmlMode === "file") { + if (!selectedFile) throw new Error(t("addData.czml.errorChooseFile")); + const doc = parseCzml(selectedFile.text); + if (!doc) throw new Error(t("addData.czml.errorInvalidCzml")); + source.addAndClose( + createCzmlLayer({ + name, + data: doc, + sourcePath: selectedFile.path, + }), + ); + return; + } + + const trimmedUrl = czmlUrl.trim(); + if (!trimmedUrl) throw new Error(t("addData.czml.errorUrl")); + + source.addAndClose( + createCzmlLayer({ + name, + url: trimmedUrl, + }), + ); + }); + + // A quick pick adds straight from a button, so it cannot go through the + // form's `runSubmit`; mirror its error handling here. The sample is cloned so + // every layer owns its packets rather than sharing the exported constant. + const handleSelectQuickPick = (pick: (typeof CZML_QUICK_PICKS)[number]) => { + source.setLayerName(pick.name); + source.setError(null); + try { + source.addAndClose( + createCzmlLayer({ + name: pick.name, + data: structuredClone(pick.data), + }), + ); + } catch (err) { + source.setError(errorMessage(err, t("addData.shared.addError"))); + } + }; + + return ( + +
+
+ + +
+ + {czmlMode === "url" ? ( +
+ + setCzmlUrl(event.target.value)} + /> +
+ ) : ( +
+ +
+ + + {selectedFile?.path ?? t("addData.common.noFileSelected")} + +
+
+ )} + +
+ +
+ {CZML_QUICK_PICKS.map((pick) => ( + + ))} +
+

{t("addData.czml.hint")}

+
+
+
+ ); +} diff --git a/apps/geolibre-desktop/src/components/layout/add-data/types.ts b/apps/geolibre-desktop/src/components/layout/add-data/types.ts index d6a1f3786..c2e985d08 100644 --- a/apps/geolibre-desktop/src/components/layout/add-data/types.ts +++ b/apps/geolibre-desktop/src/components/layout/add-data/types.ts @@ -23,7 +23,8 @@ export type AddDataKind = | "iceberg" | "deckgl-viz" | "video" - | "cesium-ion"; + | "cesium-ion" + | "czml"; /** A data source loadable either from a remote URL or a local file. */ export type FeedMode = "url" | "file"; diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/AddDataMenu.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/AddDataMenu.tsx index 3b3177295..a2e98c442 100644 --- a/apps/geolibre-desktop/src/components/layout/toolbar/AddDataMenu.tsx +++ b/apps/geolibre-desktop/src/components/layout/toolbar/AddDataMenu.tsx @@ -102,6 +102,8 @@ export function AddDataMenu({ // Ion assets load through Cesium only (issue #2290); on the 2D map the // entry stays visible but disabled so the capability is discoverable. "cesium-ion": { onSelect: () => onSetAddDataKind("cesium-ion"), disabled: !cesiumPrimary }, + // CZML dynamic 3D scenes load through Cesium only (issue #2290). + czml: { onSelect: () => onSetAddDataKind("czml"), disabled: !cesiumPrimary }, // The glTF model opens the same deck.gl scenegraph builder, so it is // gated the way "deckgl-viz" is. "gltf-model": { onSelect: onAddGltfModel, disabled: !capabilities.customLayers }, diff --git a/apps/geolibre-desktop/src/components/panels/StylePanel.tsx b/apps/geolibre-desktop/src/components/panels/StylePanel.tsx index 89370173d..2b97991f0 100644 --- a/apps/geolibre-desktop/src/components/panels/StylePanel.tsx +++ b/apps/geolibre-desktop/src/components/panels/StylePanel.tsx @@ -26,6 +26,7 @@ import { type VectorStyleStop, collectDiagramData, geojsonHasZCoordinates, + isCzmlLayer, isStyleLibraryTargetLayer, parseJsonExpression, pluginOwnsPaint, @@ -1776,6 +1777,12 @@ export function StylePanel({ const isDeckVectorLayer = hasExternalDeckLayer(layer); const isRasterTileLayer = layer.metadata.tileType === "raster"; const isThreeDTilesLayer = layer.type === "3d-tiles"; + // A CZML scene reuses the `3d-tiles` type so the globe owns it, but + // `CesiumLayerSync` only toggles its visibility: no `Cesium3DTileStyle` is + // compiled for it and no feature filter reaches its entities, so the tileset + // symbology and quick-filter controls would be silent no-ops (#2290). + const isCzmlScene = isCzmlLayer(layer); + const hasTilesetSymbology = isThreeDTilesLayer && !isCzmlScene; // An external plugin's MapLibre custom (WebGL) layer draws its own pixels and // has no MapLibre paint properties, so every paint editor below would be inert // for it (#1445). The plugin declares that with `paintMode: "plugin"`; the @@ -1821,6 +1828,7 @@ export function StylePanel({ // `type` (a deck GeoJSON layer is still `"geojson"`), so testing the type // first would let it through even though a custom layer accepts no filter. !hasExternalDeckLayer(layer) && + !isCzmlScene && (layer.type === "geojson" || layer.type === "vector-tiles" || layer.type === "mbtiles" || @@ -4984,7 +4992,7 @@ export function StylePanel({ still has to appear for a layer restored from one. */} {hasNetcdfSymbology ? ( - ) : isThreeDTilesLayer ? ( + ) : hasTilesetSymbology ? ( // A tileset has no MapLibre paint properties, but the globe can // classify its features from the same symbology every vector // layer uses — `CesiumLayerSync` compiles the colour expression @@ -5019,7 +5027,7 @@ export function StylePanel({

- {t("style.selectedLayerType", { type: layer.type })} + {t("style.selectedLayerType", { type: isCzmlScene ? "czml" : layer.type })}

); diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 79723a04a..7092209e8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -351,6 +351,10 @@ "cesiumIon": { "label": "Add Cesium Ion Asset", "description": "Add a 3D Tiles tileset or imagery from Cesium Ion by asset id. Renders on the 3D globe with your Cesium Ion token." + }, + "czml": { + "label": "Add CZML Dynamic 3D Layer", + "description": "Load a dynamic Cesium Language (CZML) document describing orbits, trajectories, vehicle paths, and time-varying 3D scenes on the globe." } }, "shared": { @@ -420,6 +424,20 @@ "errorAltitude": "Enter the altitude offset as a number of metres.", "tokenMissing": "No Cesium Ion token is configured. Add one in Settings → Environment variables (VITE_CESIUM_TOKEN) or the asset will fail to load." }, + "czml": { + "defaultName": "CZML Dynamic Scene", + "url": "CZML URL", + "sourceModeUrl": "URL", + "sourceModeFile": "Local file", + "file": "File", + "quickPicks": "Sample dynamic scenes", + "hint": "CZML streams or files define dynamic 3D positions, animations, and orbits synchronized with the globe clock.", + "errorUrl": "Enter a valid CZML endpoint URL.", + "errorChooseFile": "Select a CZML (.czml or .json) file to load.", + "errorFileMissing": "The selected file could not be read.", + "errorInvalidCzml": "The file does not contain a valid CZML document (expected JSON array or packet object).", + "readError": "Failed to read CZML data." + }, "xyz": { "defaultName": "XYZ Layer", "sampleLabel": "USGS imagery", @@ -2508,6 +2526,7 @@ "video": "Video Layer", "deckglViz": "Deck.gl Layer", "cesiumIon": "Cesium Ion Asset", + "czml": "CZML Dynamic 3D Scene", "gltfModel": "3D Model (glTF)", "postgres": "PostgreSQL Layer", "iceberg": "Apache Iceberg Layer" diff --git a/apps/geolibre-desktop/src/lib/ui-profile.ts b/apps/geolibre-desktop/src/lib/ui-profile.ts index 0701d3f9d..55e75efbe 100644 --- a/apps/geolibre-desktop/src/lib/ui-profile.ts +++ b/apps/geolibre-desktop/src/lib/ui-profile.ts @@ -154,6 +154,12 @@ export const DATA_SOURCE_CATALOG: readonly DataSourceCatalogEntry[] = [ labelKey: "toolbar.layerType.cesiumIon", tier: "advanced", }, + { + id: "czml", + section: "threeD", + labelKey: "toolbar.layerType.czml", + tier: "advanced", + }, { id: "gltf-model", section: "threeD", diff --git a/docs/mcp.md b/docs/mcp.md index 0f3e37229..30c5bc37e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -104,6 +104,7 @@ Give it a directory meant for maps, not your home directory. | `add_ogc_layer` | WMS and WMTS endpoints. | | `add_3d_tiles_layer` | OGC 3D Tiles tilesets, by URL or Cesium Ion asset id. | | `add_cesium_ion_layer` | Cesium Ion assets (tileset or imagery) by id; rendered by the 3D globe only. | +| `add_czml_layer` | A CZML (Cesium Language) dynamic 3D scene, by URL or inline packets; rendered by the 3D globe only. | ### Editing diff --git a/docs/python.md b/docs/python.md index 91f84a025..990d36aec 100644 --- a/docs/python.md +++ b/docs/python.md @@ -296,6 +296,7 @@ m.on_layer_change(lambda e: print("layers", e["layerIds"])) | `add_raster(source, name=, bands=, colormap=, rescale=, array_args=, **style)` | Add a COG/GeoTIFF URL or path, or an xarray DataArray/Dataset (xarray needs `geolibre[raster]`). | | `add_3d_tiles(url=None, name=, ion_asset_id=, altitude_offset=, request_headers=, **style)` | Add a 3D Tiles `tileset.json` URL, or a Cesium Ion tileset by asset id (3D globe only). | | `add_cesium_ion(asset_id, name=, kind="3d-tiles", altitude_offset=, **style)` | Add a Cesium Ion asset by id: a 3D Tiles tileset or (`kind="imagery"`) an imagery layer. Renders on the 3D globe, with the app's Ion token. | +| `add_czml(url=None, name=, data=, source_path=, **style)` | Add a CZML (Cesium Language) dynamic 3D scene by URL or inline packets: orbits, vehicle tracks, moving models. Renders on the 3D globe, which follows the document's clock. | | `add_video(urls, coordinates, name=, **style)` | Add a georeferenced video (four `[lng, lat]` corners). | | `add_basemap(basemap)` | Set the background basemap. | | `split_map(left_layers=None, right_layers=None, orientation=, position=, control_position=)` | Add a swipe (split-map) comparison slider between two layer sets. | diff --git a/packages/core/src/cesium-ion.ts b/packages/core/src/cesium-ion.ts index aefd9a6ba..ac5605b36 100644 --- a/packages/core/src/cesium-ion.ts +++ b/packages/core/src/cesium-ion.ts @@ -1,3 +1,4 @@ +import { isCzmlLayer } from "./czml"; import { DEFAULT_LAYER_STYLE, type GeoLibreLayer } from "./types"; // Cesium Ion assets (issue #2290): 3D Tiles and imagery referenced by Ion @@ -57,7 +58,7 @@ export function cesiumIonAssetKind(layer: Pick): CesiumIo * `isCesiumSupportedLayerType`, for the Layers panel to badge on the 2D map. */ export function isCesiumOnlyLayer(layer: Pick): boolean { - return isCesiumIonLayer(layer); + return isCesiumIonLayer(layer) || isCzmlLayer(layer); } function newLayerId(): string { diff --git a/packages/core/src/czml.ts b/packages/core/src/czml.ts new file mode 100644 index 000000000..3e4f6024c --- /dev/null +++ b/packages/core/src/czml.ts @@ -0,0 +1,219 @@ +import { DEFAULT_LAYER_STYLE, type GeoLibreLayer } from "./types"; + +/** + * CZML (Cesium Language) domain models and layer authoring (issue #2290). + * + * CZML is a JSON-based format for describing dynamic, time-varying 3D geospatial + * scenes on the Cesium globe (trajectories, satellites, vehicles, sensor + * networks, moving entities with orientations and paths). The globe loads CZML + * natively through `CzmlDataSource`; the 2D map has no equivalent, so CZML + * layers are badged "3D only" there. + */ + +/** `metadata.sourceKind` of a layer that carries or references CZML data. */ +export const CZML_SOURCE_KIND = "czml"; + +/** One CZML packet in a document stream. */ +export type CzmlPacket = Record; + +/** A CZML document represented as an array of packets. */ +export type CzmlDocument = CzmlPacket[]; + +/** Minimal Point sample in CZML. */ +export const CZML_SAMPLE_POINT: CzmlPacket[] = [ + { + id: "document", + name: "CZML Point Sample", + version: "1.0", + }, + { + id: "point 1", + name: "Extruded Point", + position: { + cartographicDegrees: [-75.59777, 40.03883, 1000], + }, + point: { + color: { + rgba: [255, 128, 0, 255], + }, + pixelSize: 14, + outlineColor: { + rgba: [255, 255, 255, 255], + }, + outlineWidth: 2, + }, + }, +]; + +/** Time-dynamic trajectory sample with an orbit track and clock. */ +export const CZML_SAMPLE_DYNAMIC: CzmlPacket[] = [ + { + id: "document", + name: "CZML Trajectory Sample", + version: "1.0", + clock: { + interval: "2026-09-09T00:00:00Z/2026-09-09T02:00:00Z", + currentTime: "2026-09-09T00:00:00Z", + multiplier: 60, + range: "LOOP_STOP", + }, + }, + { + id: "satellite", + name: "Satellite Track", + availability: "2026-09-09T00:00:00Z/2026-09-09T02:00:00Z", + path: { + material: { + solidColor: { + color: { + rgba: [0, 200, 255, 255], + }, + }, + }, + width: 2, + leadTime: 1800, + trailTime: 1800, + }, + position: { + epoch: "2026-09-09T00:00:00Z", + cartographicDegrees: [ + 0, -75, 40, 250000, 1800, -30, 20, 250000, 3600, 20, 0, 250000, 5400, 70, -20, 250000, 7200, + 120, -40, 250000, + ], + }, + point: { + color: { + rgba: [255, 255, 255, 255], + }, + pixelSize: 10, + }, + }, +]; + +/** One-click CZML samples offered in the Add Data dialog. */ +export const CZML_QUICK_PICKS: ReadonlyArray<{ + name: string; + data: CzmlPacket[]; +}> = [ + { name: "CZML Point", data: CZML_SAMPLE_POINT }, + { name: "CZML Dynamic Trajectory", data: CZML_SAMPLE_DYNAMIC }, +]; + +/** + * Parse and validate a CZML payload from a JSON string or raw object. + * Returns an array of CZML packets or null when the input is malformed. + */ +export function parseCzml(value: unknown): CzmlPacket[] | null { + if (!value) return null; + let parsed = value; + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed.startsWith("[") && !trimmed.startsWith("{")) return null; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + } + if (Array.isArray(parsed)) { + return parsed.length > 0 && parsed.every((item) => item && typeof item === "object") + ? (parsed as CzmlPacket[]) + : null; + } + if (parsed && typeof parsed === "object") { + return [parsed as CzmlPacket]; + } + return null; +} + +/** + * Whether `layer` represents a CZML dynamic 3D scene. + */ +export function isCzmlLayer(layer: Pick): boolean { + return layer.metadata?.sourceKind === CZML_SOURCE_KIND; +} + +/** Extracted CZML source definition for a layer. */ +export interface CzmlSource { + url?: string; + data?: CzmlPacket[] | string; +} + +/** + * Extract the CZML content and/or URL from a layer, or null if not a CZML layer. + */ +export function czmlSource(layer: Pick): CzmlSource | null { + if (!isCzmlLayer(layer)) return null; + const raw = layer.source?.czmlData as CzmlPacket[] | CzmlPacket | string | undefined; + // An empty packet array is a document with nothing in it, not a document; a + // bare packet (the Python API accepts one) is wrapped the way `parseCzml` + // does, since Cesium indexes a document as an array. + const data = Array.isArray(raw) + ? raw.length > 0 + ? raw + : undefined + : raw && typeof raw === "object" + ? [raw] + : raw || undefined; + const rawUrl = layer.source?.url; + const url = typeof rawUrl === "string" && rawUrl.trim() ? rawUrl.trim() : undefined; + if (!data && !url) return null; + return { url, data }; +} + +/** Generate a unique identifier for a newly created layer. */ +function newLayerId(): string { + return typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +/** + * Options for constructing a CZML layer. + */ +export interface CzmlLayerOptions { + /** Optional layer id; defaults to a new UUID. */ + id?: string; + /** Display name of the layer in the Layers panel. */ + name: string; + /** Inlined CZML packet array or serialized JSON string. */ + data?: CzmlPacket[] | string; + /** Remote URL pointing to a .czml document. */ + url?: string; + /** Local path when loaded from disk. */ + sourcePath?: string; +} + +/** + * Build a typed GeoLibreLayer representing a CZML dynamic scene on the Cesium globe. + */ +export function createCzmlLayer(options: CzmlLayerOptions): GeoLibreLayer { + const id = options.id ?? newLayerId(); + const data = options.data; + const url = options.url?.trim() || undefined; + const sourcePath = options.sourcePath?.trim() || undefined; + + return { + id, + name: options.name, + type: "3d-tiles", + ...(sourcePath ? { sourcePath } : {}), + source: { + type: "3d-tiles", + sourceId: id, + ...(data !== undefined ? { czmlData: data } : {}), + ...(url ? { url } : {}), + ...(sourcePath ? { sourcePath } : {}), + }, + visible: true, + opacity: 1, + style: { ...DEFAULT_LAYER_STYLE }, + metadata: { + sourceKind: CZML_SOURCE_KIND, + externalNativeLayer: true, + identifiable: false, + sourceId: id, + nativeLayerIds: [id], + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ee057acf2..5a725daaf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -153,6 +153,18 @@ export { type CesiumIonAssetKind, type CesiumIonLayerOptions, } from "./cesium-ion"; +export { + CZML_QUICK_PICKS, + CZML_SOURCE_KIND, + createCzmlLayer, + czmlSource, + isCzmlLayer, + parseCzml, + type CzmlDocument, + type CzmlLayerOptions, + type CzmlPacket, + type CzmlSource, +} from "./czml"; export { GOOGLE_MAPS_API_KEY_HEADER, googleMapsApiKeyHeaderValue, diff --git a/packages/map/src/cesium-layer-sync.ts b/packages/map/src/cesium-layer-sync.ts index 99beeb661..fec2663d0 100644 --- a/packages/map/src/cesium-layer-sync.ts +++ b/packages/map/src/cesium-layer-sync.ts @@ -2,9 +2,12 @@ import { cesiumIonAssetId, compileFeatureExpression, compileQuickFilters, + czmlSource, DEFAULT_LAYER_STYLE, geojsonHasZCoordinates, getCesiumIonToken, + isCzmlLayer, + parseCzml, resolveThreeDTilesRequestHeaders, ruleBasedVisibilityFilter, transformGeojsonElevation, @@ -175,7 +178,21 @@ const BOUNDING_SPHERE_STATE_PENDING = 1; */ const ARCGIS_MAP_SERVICE_KIND = "arcgis-map-service"; -type EntryKind = "imagery" | "geojson" | "3dtiles" | "points" | "pointcloud"; +type EntryKind = "imagery" | "geojson" | "3dtiles" | "points" | "pointcloud" | "czml"; + +/** The `clock` packet a loaded CZML data source carries, as Cesium exposes it. */ +interface CzmlDocumentClock { + startTime?: unknown; + stopTime?: unknown; + currentTime?: unknown; + clockRange?: unknown; + multiplier?: unknown; +} + +/** The document clock of a loaded CZML data source, or undefined without one. */ +function czmlDocumentClock(handle: unknown): CzmlDocumentClock | undefined { + return (handle as { clock?: CzmlDocumentClock } | null | undefined)?.clock ?? undefined; +} /** The slice of a rendered tile's content the attribute-name discovery reads. */ interface TileContentLike { @@ -203,6 +220,7 @@ function pointCloudUrl(layer: GeoLibreLayer): string | undefined { * file has no globe loader), or a point cloud already in 3D Tiles form. */ function isTilesetLayer(layer: GeoLibreLayer): boolean { + if (isCzmlLayer(layer)) return false; if (layer.type === "3d-tiles") return true; if (layer.type === "gaussian-splat") return isSplatTilesetUrl(str(layer.source.url) ?? str(layer.sourcePath)); @@ -485,6 +503,7 @@ function wmtsCapabilities( */ export function isCesiumSupportedLayerType(layer: GeoLibreLayer): boolean { return ( + isCzmlLayer(layer) || hasGeoJsonCollection(layer) || layer.type === "geojson" || isTilesetLayer(layer) || @@ -499,6 +518,10 @@ export function isCesiumSupportedLayerType(layer: GeoLibreLayer): boolean { /** Whether this layer can render on the globe now (kind supported + data ready). */ function isSupported(layer: GeoLibreLayer): boolean { if (!isCesiumSupportedLayerType(layer)) return false; + if (isCzmlLayer(layer)) { + const src = czmlSource(layer); + return Boolean(src && (src.url || src.data)); + } if (hasRenderableGeoJson(layer)) return true; // A layer that carries a FeatureCollection renders from it or not at all. // Falling through to the imagery checks below would let an incidental @@ -660,7 +683,14 @@ export function imageryColorAdjustments(style: LayerStyle | undefined): { }; } +/** + * Determine the synchronizer entry kind for a given layer. + * + * @param layer The layer to evaluate. + * @returns The EntryKind categorization for the globe renderer. + */ function entryKind(layer: GeoLibreLayer): EntryKind { + if (isCzmlLayer(layer)) return "czml"; if (hasRenderableGeoJson(layer)) return planPointRendering(layer).batched ? "points" : "geojson"; if (isTilesetLayer(layer)) return "3dtiles"; if (isDecodedPointCloudLayer(layer)) return "pointcloud"; @@ -806,6 +836,14 @@ function needsRebuild(prev: GeoLibreLayer, next: GeoLibreLayer): boolean { JSON.stringify(next.source.requestHeaders ?? null) || prev.source.altitudeOffset !== next.source.altitudeOffset ); + case "czml": + return ( + czmlSource(prev)?.url !== czmlSource(next)?.url || + // The raw store value, not `czmlSource().data`: that wraps a bare + // packet in a fresh array per call, which would read as a change. + prev.source.czmlData !== next.source.czmlData || + str(prev.sourcePath) !== str(next.sourcePath) + ); } } @@ -1056,6 +1094,11 @@ export class CesiumLayerSync { } } } + } else if (entry.kind === "czml") { + const ds = entry.handle as DataSource; + if (ds.isLoading || !entry.added) { + pending.push(layer.name); + } } else if (entry.kind === "imagery" && !(entry.handle as ImageryLayer).ready) pending.push(layer.name); } @@ -1207,11 +1250,15 @@ export class CesiumLayerSync { } this.applyHighlight(); this.watchCameraZoom(); + // Reordering two loaded CZML layers changes which one comes first. + this.electCzmlClockOwner(); } destroy(): void { this.restoreHighlight(); this.selection = null; + // Nothing to hand the clock to while everything is torn down. + this.czmlClockOwner = undefined; for (const entry of this.entries.values()) this.destroyEntry(entry); this.entries.clear(); this.removeDrapeLayer(); @@ -1578,6 +1625,7 @@ export class CesiumLayerSync { this.entries.set(layer.id, entry); if (kind === "imagery") void this.createImagery(entry); else if (kind === "geojson") void this.createGeoJson(entry); + else if (kind === "czml") void this.createCzml(entry); else if (kind === "pointcloud") void this.createPointCloud(entry); else if (kind === "points") this.createPointBatch(entry); else void this.createTileset(entry); @@ -2286,6 +2334,85 @@ export class CesiumLayerSync { } } + /** + * Load a CZML (Cesium Language) document as a dynamic 3D scene (issue #2290). + * Supports URL endpoints or inline packets (an array, or the same serialized + * as a JSON string) with dynamic time-tagged positions, orbits, models, paths, + * and clock synchronization. Which document drives the viewer clock is + * decided by {@link electCzmlClockOwner}. + * + * @param entry The synchronizer entry tracking this CZML layer. + */ + private async createCzml(entry: LayerEntry): Promise { + const { Cesium, viewer } = this; + const source = czmlSource(entry.layer); + if (!source) return; + // `CzmlDataSource.load` treats a string as a URL to fetch, so a serialized + // inline document has to be parsed before it reaches Cesium. + const inline = typeof source.data === "string" ? parseCzml(source.data) : source.data; + if (typeof source.data === "string" && !inline) { + entry.loadError = "Invalid CZML document"; + return; + } + const target = inline ?? source.url; + if (!target) return; + + try { + const dataSource = await Cesium.CzmlDataSource.load(target); + if (entry.cancelled) return; + + entry.handle = dataSource; + dataSource.show = entry.layer.visible; + + await viewer.dataSources.add(dataSource); + if (entry.cancelled) { + viewer.dataSources.remove(dataSource, true); + return; + } + entry.added = true; + // Only a document that reached the scene may drive the clock. + this.electCzmlClockOwner(); + viewer.scene?.requestRender?.(); + } catch (error) { + if (entry.cancelled) return; + entry.loadError = error instanceof Error ? error.message : String(error); + } + } + + /** + * Re-elect the CZML layer that drives the viewer clock: the first layer in + * the synced layer order whose loaded document carries a `clock` packet. The + * globe has a single clock, so election goes by layer order rather than by + * load completion — two documents loading in parallel settle the same way + * every time — and removing the owner hands the clock to the next document + * instead of leaving the viewer on a stale interval. Nothing is written while + * the owner stays the same, so a later CZML load never resets the Time + * Slider's position, which keeps driving `currentTime` through + * {@link setTime}. When the last CZML layer leaves, the clock is left where + * that document set it: the Time Slider owns time from then on, and nothing + * else on the globe expects a particular interval. + */ + private electCzmlClockOwner(): void { + let owner: LayerEntry | undefined; + for (const layer of this.currentLayers) { + const entry = this.entries.get(layer.id); + if (entry?.kind !== "czml" || entry.cancelled || !entry.added) continue; + if (!czmlDocumentClock(entry.handle)) continue; + owner = entry; + break; + } + if (owner?.layer.id === this.czmlClockOwner) return; + this.czmlClockOwner = owner?.layer.id; + const clock = owner ? czmlDocumentClock(owner.handle) : undefined; + const viewerClock = this.viewer.clock; + if (!clock || !viewerClock) return; + if (clock.startTime) viewerClock.startTime = clock.startTime as never; + if (clock.stopTime) viewerClock.stopTime = clock.stopTime as never; + if (clock.currentTime) viewerClock.currentTime = clock.currentTime as never; + if (clock.clockRange !== undefined) viewerClock.clockRange = clock.clockRange as never; + if (clock.multiplier !== undefined) viewerClock.multiplier = clock.multiplier as never; + } + private async createTileset(entry: LayerEntry): Promise { const { Cesium, viewer } = this; const layer = entry.layer; @@ -2378,6 +2505,11 @@ export class CesiumLayerSync { tileset.modelMatrix = Cesium.Matrix4.fromTranslation(translation); } + /** + * Apply visual appearance (visibility, opacity, symbology, filters) to an active entry. + * + * @param entry The layer entry to update on the globe. + */ private applyAppearance(entry: LayerEntry): void { const { handle, layer } = entry; if (!handle) return; @@ -2397,6 +2529,8 @@ export class CesiumLayerSync { (handle as DataSource).show = layer.visible; this.applyGeoJsonStyle(entry); this.applyGeoJsonFilter(entry); + } else if (entry.kind === "czml") { + (handle as DataSource).show = layer.visible; } else if (entry.kind === "pointcloud") { const collection = handle as PointPrimitiveCollection; collection.show = layer.visible; @@ -2904,6 +3038,17 @@ export class CesiumLayerSync { /** Fill-pattern repeat counts, by entity; see {@link patternRepeat}. */ private readonly patternRepeats = new WeakMap(); + /** + * Id of the CZML layer whose document `clock` the viewer clock follows; see + * {@link electCzmlClockOwner}. Re-elected when that layer is destroyed. + */ + private czmlClockOwner: string | undefined; + + /** + * Tear down an entry and release its Cesium resources from the scene. + * + * @param entry The layer entry being destroyed. + */ private destroyEntry(entry: LayerEntry): void { entry.cancelled = true; entry.abort?.abort(); @@ -2921,7 +3066,9 @@ export class CesiumLayerSync { const provider = imagery.imageryProvider as { destroy?: () => void } | undefined; this.viewer.imageryLayers.remove(imagery, true); if (provider instanceof ProtocolImageryProvider) provider.destroy(); - } else if (entry.kind === "geojson") { + } else if (entry.kind === "geojson" || entry.kind === "czml") { + // `cancelled` is already set, so the election skips this entry. + if (this.czmlClockOwner === entry.layer.id) this.electCzmlClockOwner(); entry.cluster?.dispose(); entry.cluster = undefined; // A cancelled entry can hold a data source that never reached the scene; diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index a6d567524..31d8db0fd 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -2700,6 +2700,39 @@ def add_cesium_ion( ) ) + def add_czml( + self, + url: str | None = None, + name: str = "CZML scene", + *, + data: list[dict[str, Any]] | dict[str, Any] | None = None, + source_path: str | None = None, + **style: Any, + ) -> str: + """Add a CZML (Cesium Language) dynamic 3D scene. + + CZML describes time-varying scenes (satellite orbits, vehicle tracks, + moving models with paths). The 3D globe loads it natively and follows + the document's clock; the 2D map badges the layer "3D only". + + Args: + url: URL of a ``.czml`` document. + name: Layer display name. + data: Inline CZML packets (a list, or one packet dict) instead of + a URL. + source_path: Local path the document was loaded from, if any. + **style: Style overrides. + + Returns: + The id of the added layer. + + Raises: + ValueError: If neither ``url`` nor ``data`` is given. + """ + return self._add_layer( + _project.czml_layer(name, url=url, data=data, source_path=source_path, **style) + ) + def add_video( self, urls: str | list[str], diff --git a/python/src/geolibre/mcp/server.py b/python/src/geolibre/mcp/server.py index a054b14d6..b2b833910 100644 --- a/python/src/geolibre/mcp/server.py +++ b/python/src/geolibre/mcp/server.py @@ -62,6 +62,8 @@ - `add_ogc_layer` - a WMS or WMTS endpoint. - `add_3d_tiles_layer` - an OGC 3D Tiles tileset (URL or Cesium Ion asset id). - `add_cesium_ion_layer` - a Cesium Ion asset (tileset or imagery) by id, 3D globe only. +- `add_czml_layer` - a CZML dynamic 3D scene (orbits, vehicle tracks) by URL or + inline packets, 3D globe only. Layers are referenced by id or by display name. `describe_project` is the cheap way to see what a project currently holds; it never echoes back inlined @@ -720,6 +722,35 @@ def add_cesium_ion_layer( ) return add(path, layer, index) + @tool() + def add_czml_layer( + path: str, + name: str, + url: str | None = None, + data: list[dict[str, Any]] | dict[str, Any] | None = None, + index: int | None = None, + ) -> dict[str, Any]: + """Add a CZML (Cesium Language) dynamic 3D scene: orbits, tracks, moving models. + + Pass either the URL of a `.czml` document or its packets inline. Renders + on the 3D globe only (set the project's `primaryRenderer` to + `"cesium"`), which follows the document's `clock` packet for playback. + + Args: + path: Path to the `.geolibre.json` file. + name: The layer's display name. + url: An `http(s)://` URL of a `.czml` document. + data: The CZML packet array (or a single packet) to inline instead + of a URL; the first packet is normally + `{"id": "document", "version": "1.0"}`. + index: Draw-order position; appended on top when omitted. + + Returns: + The new layer's id and the project's updated layer count. + """ + layer = _project.czml_layer(name, url=url, data=data) + return add(path, layer, index) + # -- editing layers ------------------------------------------------------- @tool() diff --git a/python/src/geolibre/project.py b/python/src/geolibre/project.py index c1b407046..ccfe3268b 100644 --- a/python/src/geolibre/project.py +++ b/python/src/geolibre/project.py @@ -1693,6 +1693,66 @@ def cesium_ion_layer( return layer +CZML_SOURCE_KIND = "czml" +"""``metadata.sourceKind`` of a layer that references a CZML dynamic scene.""" + + +def czml_layer( + name: str, + *, + url: str | None = None, + data: list[dict[str, Any]] | dict[str, Any] | None = None, + source_path: str | None = None, + **style: Any, +) -> dict[str, Any]: + """Build a layer that loads a CZML (Cesium Language) dynamic 3D scene. + + The shape matches ``createCzmlLayer`` in ``@geolibre/core``: a + ``3d-tiles`` layer marked external so the 2D map leaves it alone and badges + it "3D only". The globe renders dynamic orbits, vehicle paths, and time-varying + scenes from CZML packets with clock synchronization. + + Args: + name: Layer display name. + url: URL endpoint serving the CZML document. + data: Inline parsed CZML document (packets array or packet object). + source_path: Optional local file path when loaded from disk. + **style: Style overrides merged into the default layer style. + + Returns: + A layer dict for the project's ``layers`` array. + + Raises: + ValueError: If neither ``url`` nor a non-empty ``data`` is provided. + """ + if not url and not data: + raise ValueError("Either url or non-empty data must be provided for a CZML layer") + layer = _layer_base(name, "3d-tiles", **style) + source_id = layer["id"] + source: dict[str, Any] = { + "type": "3d-tiles", + "sourceId": source_id, + } + if url: + source["url"] = url + if data: + source["czmlData"] = data + if source_path: + source["sourcePath"] = source_path + layer["sourcePath"] = source_path + + metadata: dict[str, Any] = { + "sourceKind": CZML_SOURCE_KIND, + "externalNativeLayer": True, + "identifiable": False, + "sourceId": source_id, + "nativeLayerIds": [source_id], + } + layer["source"] = source + layer["metadata"] = metadata + return layer + + def video_layer( name: str, urls: list[str], diff --git a/python/tests/test_czml.py b/python/tests/test_czml.py new file mode 100644 index 000000000..18e4eb936 --- /dev/null +++ b/python/tests/test_czml.py @@ -0,0 +1,46 @@ +"""CZML dynamic 3D scene layers (issue #2290): builders, the Map API, and the MCP tool.""" + +from __future__ import annotations + +import pytest + +from geolibre import project + + +def test_czml_layer_url_shape(): + layer = project.czml_layer("Satellites", url="https://example.com/sat.czml") + assert layer["type"] == "3d-tiles" + assert layer["source"] == { + "type": "3d-tiles", + "url": "https://example.com/sat.czml", + "sourceId": layer["id"], + } + md = layer["metadata"] + assert md["sourceKind"] == "czml" + assert md["externalNativeLayer"] is True + assert md["identifiable"] is False + # No customLayerType, like createCzmlLayer: the globe sync renders it, so + # the Layer Library needs no restore pass to re-add it. + assert "customLayerType" not in md + assert md["nativeLayerIds"] == [layer["id"]] + assert "sourcePath" not in layer + + +def test_czml_layer_data_shape(): + packets = [ + {"id": "document", "name": "Dynamic", "version": "1.0"}, + {"id": "orbit", "point": {"pixelSize": 10}}, + ] + layer = project.czml_layer("Inline Orbit", data=packets, source_path="/local/orbit.czml") + assert layer["type"] == "3d-tiles" + assert layer["source"]["czmlData"] == packets + assert layer["source"]["sourcePath"] == "/local/orbit.czml" + assert layer["sourcePath"] == "/local/orbit.czml" + assert layer["metadata"]["sourceKind"] == "czml" + + +@pytest.mark.parametrize("kwargs", [{}, {"data": []}, {"data": {}}]) +def test_czml_layer_requires_url_or_packets(kwargs): + """An empty document has nothing to render, so it is rejected like a missing one.""" + with pytest.raises(ValueError, match="url or non-empty data"): + project.czml_layer("Missing", **kwargs) diff --git a/python/tests/test_map.py b/python/tests/test_map.py index b1dcf4a9a..a84c5af9a 100644 --- a/python/tests/test_map.py +++ b/python/tests/test_map.py @@ -388,6 +388,24 @@ def test_add_cesium_ion_imagery(m): assert layer["metadata"]["externalNativeLayer"] is True +def test_add_czml_url(m): + m.add_czml("https://e/sat.czml", name="Satellites") + layer = _last_layer(m) + assert layer["type"] == "3d-tiles" + assert layer["name"] == "Satellites" + assert layer["source"]["url"] == "https://e/sat.czml" + assert layer["metadata"]["sourceKind"] == "czml" + assert layer["metadata"]["externalNativeLayer"] is True + + +def test_add_czml_inline_packets(m): + packets = [{"id": "document", "version": "1.0"}, {"id": "p", "point": {"pixelSize": 6}}] + m.add_czml(data=packets, source_path="/local/p.czml") + layer = _last_layer(m) + assert layer["source"]["czmlData"] == packets + assert layer["sourcePath"] == "/local/p.czml" + + def test_add_video_wraps_single_url(m): m.add_video("https://e/a.mp4", [[0, 0], [1, 0], [1, 1], [0, 1]]) assert _last_layer(m)["source"]["urls"] == ["https://e/a.mp4"] diff --git a/python/tests/test_mcp_server.py b/python/tests/test_mcp_server.py index 726163973..4d2425980 100644 --- a/python/tests/test_mcp_server.py +++ b/python/tests/test_mcp_server.py @@ -444,6 +444,9 @@ def test_add_raster_layer_records_its_source(server, project_path): ("add_3d_tiles_layer", {"ion_asset_id": 96188}, "3d-tiles"), ("add_cesium_ion_layer", {"asset_id": 96188}, "3d-tiles"), ("add_cesium_ion_layer", {"asset_id": 2, "kind": "imagery"}, "raster"), + ("add_czml_layer", {"url": "https://example.com/sat.czml"}, "3d-tiles"), + ("add_czml_layer", {"data": [{"id": "document", "version": "1.0"}]}, "3d-tiles"), + ("add_czml_layer", {"data": {"id": "document", "version": "1.0"}}, "3d-tiles"), ( "add_tiles_layer", {"url": "https://example.com/a.pmtiles", "kind": "pmtiles"}, @@ -488,6 +491,20 @@ def test_cesium_ion_tools_persist_the_asset_id(server, project_path, tmp_path): assert {layer["metadata"]["sourceKind"] for layer in saved["layers"]} == {"cesium-ion"} +def test_czml_tool_persists_the_document(server, project_path, tmp_path): + """The globe loads CZML from `source.czmlData` / `source.url`, so both must survive the save.""" + packets = [{"id": "document", "version": "1.0"}, {"id": "sat", "point": {"pixelSize": 8}}] + call(server, "add_czml_layer", path=project_path, name="A", url="https://example.com/a.czml") + call(server, "add_czml_layer", path=project_path, name="B", data=packets) + saved = json.loads((tmp_path / project_path).read_text()) + assert saved["layers"][0]["source"]["url"] == "https://example.com/a.czml" + assert saved["layers"][1]["source"]["czmlData"] == packets + assert {layer["metadata"]["sourceKind"] for layer in saved["layers"]} == {"czml"} + assert "url or non-empty data" in call_error( + server, "add_czml_layer", path=project_path, name="C" + ) + + def test_add_vector_layer_rejects_an_undocumented_render_mode(server, project_path): """The tool's docstring names the accepted values; they must be the real ones.""" assert "render_mode" in call_error( diff --git a/skills/geolibre/references/mcp-tools.md b/skills/geolibre/references/mcp-tools.md index 1f2d60c06..eb2eb8880 100644 --- a/skills/geolibre/references/mcp-tools.md +++ b/skills/geolibre/references/mcp-tools.md @@ -22,6 +22,7 @@ Pick by what the data **is**: | A WMS or WMTS endpoint | `add_ogc_layer` | `service="wms"` or `"wmts"`. | | An OGC 3D Tiles tileset | `add_3d_tiles_layer` | `altitude_offset` to sit it on the ground; `ion_asset_id` instead of `url` for a Cesium Ion tileset. | | A Cesium Ion asset (tileset or imagery) | `add_cesium_ion_layer` | 3D globe only: pair it with `set_renderer` / `primaryRenderer: "cesium"`. `kind="imagery"` for imagery. | +| A CZML (Cesium Language) dynamic scene: orbits, tracks, moving models | `add_czml_layer` | 3D globe only: `url` for a `.czml` document, or `data` for its packet array inline. The globe follows the document's `clock`. | | A Shapefile, GeoPackage, KML, CSV | Convert first | Read it with GeoPandas and pass GeoJSON to `add_geojson_layer`, or use the Python API's `Map.add_shp` / `Map.add_gpkg` / `Map.add_kml` / `Map.add_csv`. | Layers draw bottom-first. Every `add_*` takes an optional `index` (draw-order @@ -63,6 +64,7 @@ add_tiles_layer(path, name, url, kind="pmtiles", tile_type="vector", source_layers=None, style=None, index=None) add_3d_tiles_layer(path, name, url=None, ion_asset_id=None, altitude_offset=0, index=None) add_cesium_ion_layer(path, name, asset_id, kind="3d-tiles", altitude_offset=0, index=None) +add_czml_layer(path, name, url=None, data=None, index=None) ``` - `add_geojson_layer(data=...)` takes an `http(s)` URL, a workspace file path, diff --git a/skills/geolibre/references/python-api.md b/skills/geolibre/references/python-api.md index bd3d49e5c..660759ecf 100644 --- a/skills/geolibre/references/python-api.md +++ b/skills/geolibre/references/python-api.md @@ -59,6 +59,7 @@ m.add_wmts(endpoint, name, bounds=None) m.add_wfs(endpoint, type_name, max_features=1000) m.add_3d_tiles(url, name, altitude_offset=0) # or ion_asset_id=96188 (3D globe only) m.add_cesium_ion(asset_id, name, kind="3d-tiles") # kind="imagery" for an imagery asset +m.add_czml(url, name) # or data=[...packets] (3D globe only) m.add_video(...) ``` diff --git a/tests/czml.test.ts b/tests/czml.test.ts new file mode 100644 index 000000000..e217a90f2 --- /dev/null +++ b/tests/czml.test.ts @@ -0,0 +1,368 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + CZML_QUICK_PICKS, + CZML_SOURCE_KIND, + createCzmlLayer, + czmlSource, + isCesiumOnlyLayer, + isCzmlLayer, + parseCzml, +} from "../packages/core/src"; +import type { GeoLibreLayer } from "../packages/core/src/types"; +import { CesiumLayerSync, isCesiumSupportedLayerType } from "../packages/map/src/cesium-layer-sync"; + +// CZML (Cesium Language) dynamic 3D scenes (issue #2290). +// Tests cover the layer builder, parser, quick picks, and CesiumLayerSync integration. + +describe("czml layer builder & parser", () => { + it("parses czml text into documents and rejects invalid input", () => { + const arrayJson = JSON.stringify([ + { id: "document", name: "test", version: "1.0" }, + { id: "sat", point: { color: { rgba: [255, 0, 0, 255] } } }, + ]); + const parsedArray = parseCzml(arrayJson); + assert.ok(Array.isArray(parsedArray)); + assert.equal(parsedArray.length, 2); + + const singleJson = JSON.stringify({ id: "document", version: "1.0" }); + const parsedSingle = parseCzml(singleJson); + assert.ok(Array.isArray(parsedSingle)); + assert.equal(parsedSingle.length, 1); + + assert.equal(parseCzml(""), null); + assert.equal(parseCzml("not json"), null); + assert.equal(parseCzml("12345"), null); + assert.equal(parseCzml("null"), null); + }); + + it("builds a CZML layer from a URL", () => { + const layer = createCzmlLayer({ + name: "Satellite Track", + url: "https://example.com/orbit.czml", + }); + assert.equal(layer.type, "3d-tiles"); + assert.equal(layer.source.url, "https://example.com/orbit.czml"); + assert.equal(layer.metadata.sourceKind, CZML_SOURCE_KIND); + assert.equal(layer.metadata.externalNativeLayer, true); + assert.equal(layer.metadata.identifiable, false); + assert.deepEqual(layer.metadata.nativeLayerIds, [layer.id]); + assert.equal(isCzmlLayer(layer), true); + assert.equal(isCesiumOnlyLayer(layer), true); + assert.equal(isCesiumSupportedLayerType(layer), true); + + const source = czmlSource(layer); + assert.ok(source); + assert.equal(source.url, "https://example.com/orbit.czml"); + assert.equal(source.data, undefined); + }); + + it("builds a CZML layer from inline data packets", () => { + const packets = [ + { id: "document", name: "Simple Point", version: "1.0" }, + { id: "point1", point: { pixelSize: 10 } }, + ]; + const layer = createCzmlLayer({ + name: "Point Sample", + data: packets, + sourcePath: "/local/data/point.czml", + }); + assert.equal(layer.type, "3d-tiles"); + assert.deepEqual(layer.source.czmlData, packets); + // The document is stored once; `czml` is only read as a legacy fallback. + assert.equal("czml" in layer.source, false); + assert.equal(layer.source.sourcePath, "/local/data/point.czml"); + assert.equal(layer.sourcePath, "/local/data/point.czml"); + assert.equal(isCzmlLayer(layer), true); + assert.equal(isCesiumOnlyLayer(layer), true); + assert.equal(isCesiumSupportedLayerType(layer), true); + + const source = czmlSource(layer); + assert.ok(source); + assert.deepEqual(source.data, packets); + }); + + it("provides valid quick picks with document packets and timestamps", () => { + assert.ok(CZML_QUICK_PICKS.length >= 2); + for (const pick of CZML_QUICK_PICKS) { + assert.ok(pick.name.length > 0); + assert.ok(Array.isArray(pick.data)); + assert.ok(pick.data.length >= 2); + const docPacket = pick.data[0]; + assert.equal(docPacket.id, "document"); + assert.equal(docPacket.version, "1.0"); + } + }); + + it("does not mistake ordinary 3D tiles or layers for CZML", () => { + const tileset: GeoLibreLayer = { + id: "plain-3d", + name: "Tileset", + type: "3d-tiles", + source: { type: "3d-tiles", url: "https://example.com/tileset.json" }, + visible: true, + opacity: 1, + style: {}, + metadata: { sourceKind: "3d-tiles-url" }, + }; + assert.equal(isCzmlLayer(tileset), false); + assert.equal(czmlSource(tileset), null); + }); +}); + +function makeGlobe() { + const calls = { + czmlLoads: [] as unknown[], + dataSourcesAdded: [] as unknown[], + dataSourcesRemoved: [] as unknown[], + }; + + const Cesium = { + CzmlDataSource: { + load: async (czml: unknown) => { + calls.czmlLoads.push(czml); + const clock = { + startTime: { dayNumber: 2459000, secondsOfDay: 0 }, + stopTime: { dayNumber: 2459001, secondsOfDay: 0 }, + currentTime: { dayNumber: 2459000, secondsOfDay: 100 }, + clockRange: 1, + multiplier: 60, + }; + return { + kind: "czml-data-source", + show: true, + clock, + isLoading: false, + entities: { values: [] }, + }; + }, + }, + Event: class { + addEventListener() { + return () => {}; + } + }, + }; + + const viewer = { + clock: { + startTime: null as unknown, + stopTime: null as unknown, + currentTime: null as unknown, + clockRange: null as unknown, + multiplier: null as unknown, + }, + camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() }, + scene: { + canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 }, + primitives: { + add: () => {}, + remove: () => {}, + }, + requestRender: () => {}, + }, + imageryLayers: { + addImageryProvider: () => ({ show: true, alpha: 1 }), + remove: () => {}, + raiseToTop: () => {}, + }, + dataSources: { + add: async (ds: unknown) => { + calls.dataSourcesAdded.push(ds); + return ds; + }, + remove: (ds: unknown) => { + calls.dataSourcesRemoved.push(ds); + }, + }, + }; + + return { calls, Cesium, viewer }; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("CesiumLayerSync with CZML", () => { + it("loads a CZML layer, adds dataSource, and syncs viewer clock", async () => { + const { calls, Cesium, viewer } = makeGlobe(); + const sync = new CesiumLayerSync(Cesium as never, viewer as never, () => 10); + + const layer = createCzmlLayer({ + id: "czml-sat", + name: "Satellite", + url: "https://example.com/sat.czml", + }); + + sync.sync([layer]); + for (let i = 0; i < 4; i++) await flush(); + + assert.equal(calls.czmlLoads.length, 1); + assert.equal(calls.czmlLoads[0], "https://example.com/sat.czml"); + assert.equal(calls.dataSourcesAdded.length, 1); + + // Verify clock synchronization + assert.deepEqual(viewer.clock.startTime, { dayNumber: 2459000, secondsOfDay: 0 }); + assert.deepEqual(viewer.clock.stopTime, { dayNumber: 2459001, secondsOfDay: 0 }); + assert.deepEqual(viewer.clock.currentTime, { dayNumber: 2459000, secondsOfDay: 100 }); + assert.equal(viewer.clock.clockRange, 1); + assert.equal(viewer.clock.multiplier, 60); + + // Verify getRenderStatus reports settled + const status = sync.getRenderStatus(); + assert.deepEqual(status.pending, []); + assert.deepEqual(status.errors, []); + + // Toggle visibility + sync.sync([{ ...layer, visible: false }]); + for (let i = 0; i < 4; i++) await flush(); + const ds = calls.dataSourcesAdded[0] as { show: boolean }; + assert.equal(ds.show, false); + + // Remove layer + sync.sync([]); + for (let i = 0; i < 4; i++) await flush(); + assert.equal(calls.dataSourcesRemoved.length, 1); + assert.equal(calls.dataSourcesRemoved[0], ds); + sync.destroy(); + }); + + it("parses a serialized inline document instead of handing Cesium a URL", async () => { + const { calls, Cesium, viewer } = makeGlobe(); + const sync = new CesiumLayerSync(Cesium as never, viewer as never, () => 10); + const packets = [ + { id: "document", name: "Serialized", version: "1.0" }, + { id: "p", point: { pixelSize: 4 } }, + ]; + + sync.sync([createCzmlLayer({ id: "czml-str", name: "Text", data: JSON.stringify(packets) })]); + for (let i = 0; i < 4; i++) await flush(); + + assert.deepEqual(calls.czmlLoads, [packets]); + assert.equal(calls.dataSourcesAdded.length, 1); + + sync.sync([createCzmlLayer({ id: "czml-bad", name: "Garbage", data: "not json" })]); + for (let i = 0; i < 4; i++) await flush(); + assert.equal(calls.czmlLoads.length, 1); + assert.match(sync.getRenderStatus().errors[0], /Garbage: Invalid CZML document/); + sync.destroy(); + }); + + it("wraps a bare packet from the Python API into a document array", async () => { + const packet = { id: "document", version: "1.0" }; + const layer = createCzmlLayer({ id: "czml-one", name: "One", data: [packet] }); + layer.source.czmlData = packet; + assert.deepEqual(czmlSource(layer)?.data, [packet]); + + // The wrap is a fresh array per call, so an unrelated store update must + // not read as a data change and reload the document. + const { calls, Cesium, viewer } = makeGlobe(); + const sync = new CesiumLayerSync(Cesium as never, viewer as never, () => 10); + sync.sync([layer]); + for (let i = 0; i < 4; i++) await flush(); + sync.sync([{ ...layer, opacity: 0.5 }]); + for (let i = 0; i < 4; i++) await flush(); + assert.equal(calls.czmlLoads.length, 1); + sync.destroy(); + }); + + it("treats an empty packet array as no document", () => { + const layer = createCzmlLayer({ id: "czml-empty", name: "Empty", data: [] }); + assert.equal(czmlSource(layer), null); + assert.equal(isCesiumSupportedLayerType(layer), true); + }); + + it("elects the clock owner by layer order and re-elects when the owner leaves", async () => { + const { calls, Cesium, viewer } = makeGlobe(); + const multipliers: Record = { + "https://example.com/a.czml": 10, + "https://example.com/b.czml": 20, + "https://example.com/c.czml": 30, + }; + Cesium.CzmlDataSource.load = async (czml: unknown) => { + calls.czmlLoads.push(czml); + // `a` resolves after `b` even though it comes first in layer order. + if (czml === "https://example.com/a.czml") await new Promise((r) => setTimeout(r, 20)); + return { + kind: "czml-data-source", + show: true, + clock: { multiplier: multipliers[czml as string], currentTime: `t-${czml}` }, + isLoading: false, + entities: { values: [] }, + }; + }; + const sync = new CesiumLayerSync(Cesium as never, viewer as never, () => 10); + const a = createCzmlLayer({ id: "czml-a", name: "A", url: "https://example.com/a.czml" }); + const b = createCzmlLayer({ id: "czml-b", name: "B", url: "https://example.com/b.czml" }); + const c = createCzmlLayer({ id: "czml-c", name: "C", url: "https://example.com/c.czml" }); + + sync.sync([a, b]); + await new Promise((r) => setTimeout(r, 40)); + for (let i = 0; i < 4; i++) await flush(); + // Out-of-order loads still settle on the first layer. + assert.equal(viewer.clock.multiplier, 10); + assert.equal(viewer.clock.currentTime, "t-https://example.com/a.czml"); + + // The user (or the Time Slider) moved the clock; a later document must not + // stomp it while the owner is unchanged. + viewer.clock.multiplier = 5; + viewer.clock.currentTime = "scrubbed"; + sync.sync([a, b, c]); + for (let i = 0; i < 4; i++) await flush(); + assert.equal(viewer.clock.multiplier, 5); + assert.equal(viewer.clock.currentTime, "scrubbed"); + + // Removing the owner hands the clock to the next loaded document, without + // reloading it. + sync.sync([b, c]); + for (let i = 0; i < 4; i++) await flush(); + assert.equal(viewer.clock.multiplier, 20); + assert.equal(viewer.clock.currentTime, "t-https://example.com/b.czml"); + assert.equal(calls.czmlLoads.length, 3); + + // Reordering already-loaded documents re-elects without a reload. + sync.sync([c, b]); + for (let i = 0; i < 4; i++) await flush(); + assert.equal(viewer.clock.multiplier, 30); + assert.equal(calls.czmlLoads.length, 3); + + sync.sync([c]); + for (let i = 0; i < 4; i++) await flush(); + assert.equal(viewer.clock.multiplier, 30); + sync.destroy(); + }); + + it("does not hand the clock to a document that never reached the scene", async () => { + const { Cesium, viewer } = makeGlobe(); + viewer.dataSources.add = async () => { + throw new Error("scene rejected the data source"); + }; + const sync = new CesiumLayerSync(Cesium as never, viewer as never, () => 10); + sync.sync([createCzmlLayer({ id: "czml-x", name: "X", url: "https://example.com/x.czml" })]); + for (let i = 0; i < 4; i++) await flush(); + assert.equal(viewer.clock.multiplier, null); + assert.match(sync.getRenderStatus().errors[0], /scene rejected/); + sync.destroy(); + }); + + it("handles load errors gracefully and reports in getRenderStatus", async () => { + const { Cesium, viewer } = makeGlobe(); + Cesium.CzmlDataSource.load = async () => { + throw new Error("Network timeout loading CZML"); + }; + + const sync = new CesiumLayerSync(Cesium as never, viewer as never, () => 10); + const layer = createCzmlLayer({ + id: "czml-fail", + name: "Broken Orbit", + url: "https://example.com/broken.czml", + }); + + sync.sync([layer]); + for (let i = 0; i < 4; i++) await flush(); + + const status = sync.getRenderStatus(); + assert.equal(status.errors.length, 1); + assert.match(status.errors[0], /Broken Orbit: Network timeout loading CZML/); + sync.destroy(); + }); +});