From 67859b20523e43fee34727885837ba5c7523e146 Mon Sep 17 00:00:00 2001 From: Rohith Pariki Date: Wed, 9 Sep 2026 20:55:47 +0530 Subject: [PATCH 01/10] feat(cesium): support CZML dynamic 3D scenes on the globe --- .../src/components/layout/AddDataDialog.tsx | 3 + .../components/layout/add-data/constants.ts | 4 +- .../layout/add-data/sources/CzmlSource.tsx | 178 +++++++++++++ .../src/components/layout/add-data/types.ts | 3 +- .../components/layout/toolbar/AddDataMenu.tsx | 2 + .../geolibre-desktop/src/i18n/locales/en.json | 16 ++ apps/geolibre-desktop/src/lib/ui-profile.ts | 6 + packages/core/src/cesium-ion.ts | 4 +- packages/core/src/czml.ts | 214 +++++++++++++++ packages/core/src/index.ts | 12 + packages/map/src/cesium-layer-sync.ts | 88 ++++++- python/src/geolibre/geolibre.py | 30 +++ python/src/geolibre/mcp/server.py | 28 ++ python/src/geolibre/project.py | 61 +++++ python/tests/test_czml.py | 50 ++++ tests/czml.test.ts | 248 ++++++++++++++++++ 16 files changed, 942 insertions(+), 5 deletions(-) create mode 100644 apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx create mode 100644 packages/core/src/czml.ts create mode 100644 python/tests/test_czml.py create mode 100644 tests/czml.test.ts 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..e16860ded --- /dev/null +++ b/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx @@ -0,0 +1,178 @@ +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, + }), + ); + }); + + const handleSelectQuickPick = (pick: (typeof CZML_QUICK_PICKS)[number]) => { + source.setLayerName(pick.name); + source.setError(null); + source.addAndClose( + createCzmlLayer({ + name: pick.name, + data: pick.data, + }), + ); + }; + + 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/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 6ae1d0019..a48b544a3 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,17 @@ "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", + "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", @@ -2507,6 +2522,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/packages/core/src/cesium-ion.ts b/packages/core/src/cesium-ion.ts index aefd9a6ba..c95d038a1 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 @@ -52,12 +53,13 @@ export function cesiumIonAssetKind(layer: Pick): CesiumIo return layer.type === "3d-tiles" ? "3d-tiles" : "imagery"; } + /** * Whether only the 3D globe can render `layer`: the mirror of the globe's * `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..d1c4e78a5 --- /dev/null +++ b/packages/core/src/czml.ts @@ -0,0 +1,214 @@ +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; + +/** 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 data = (layer.source?.czmlData ?? layer.source?.czml) as CzmlPacket[] | string | undefined; + const rawUrl = layer.source?.url ?? layer.metadata?.czmlUrl; + 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. */ + czml?: CzmlPacket[] | string; + /** Inlined CZML packet array or serialized JSON string (alias for czml). */ + 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 ?? options.czml; + 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, czml: 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], + ...(url ? { czmlUrl: url } : {}), + }, + }; +} 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..cbc2a30ff 100644 --- a/packages/map/src/cesium-layer-sync.ts +++ b/packages/map/src/cesium-layer-sync.ts @@ -2,9 +2,11 @@ import { cesiumIonAssetId, compileFeatureExpression, compileQuickFilters, + czmlSource, DEFAULT_LAYER_STYLE, geojsonHasZCoordinates, getCesiumIonToken, + isCzmlLayer, resolveThreeDTilesRequestHeaders, ruleBasedVisibilityFilter, transformGeojsonElevation, @@ -66,6 +68,7 @@ import type { Cesium3DTileset, CesiumWidget, Color, + CzmlDataSource, DataSource, DistanceDisplayCondition, Entity, @@ -175,7 +178,7 @@ 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 slice of a rendered tile's content the attribute-name discovery reads. */ interface TileContentLike { @@ -203,6 +206,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 +489,7 @@ function wmtsCapabilities( */ export function isCesiumSupportedLayerType(layer: GeoLibreLayer): boolean { return ( + isCzmlLayer(layer) || hasGeoJsonCollection(layer) || layer.type === "geojson" || isTilesetLayer(layer) || @@ -499,6 +504,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 +669,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"; @@ -1056,6 +1072,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); } @@ -1578,6 +1599,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 +2308,56 @@ export class CesiumLayerSync { } } + /** + * Load a CZML (Cesium Language) document as a dynamic 3D scene (issue #2290). + * Supports URL endpoints or inline parsed CZML document packets with dynamic + * time-tagged positions, orbits, models, paths, and clock synchronization. + * + * @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; + const target = source.data ?? source.url; + if (!target) return; + + try { + const dataSource = await Cesium.CzmlDataSource.load(target as string | object); + if (entry.cancelled) return; + + entry.handle = dataSource; + dataSource.show = entry.layer.visible; + + const dsClock = (dataSource as unknown as { clock?: { + startTime?: unknown; + stopTime?: unknown; + currentTime?: unknown; + clockRange?: unknown; + multiplier?: unknown; + } }).clock; + + if (dsClock && viewer.clock) { + if (dsClock.startTime) viewer.clock.startTime = dsClock.startTime as never; + if (dsClock.stopTime) viewer.clock.stopTime = dsClock.stopTime as never; + if (dsClock.currentTime) viewer.clock.currentTime = dsClock.currentTime as never; + if (dsClock.clockRange !== undefined) viewer.clock.clockRange = dsClock.clockRange as never; + if (dsClock.multiplier !== undefined) viewer.clock.multiplier = dsClock.multiplier as never; + } + + await viewer.dataSources.add(dataSource); + if (entry.cancelled) { + viewer.dataSources.remove(dataSource, true); + return; + } + entry.added = true; + viewer.scene?.requestRender?.(); + } catch (error) { + if (entry.cancelled) return; + entry.loadError = error instanceof Error ? error.message : String(error); + } + } + private async createTileset(entry: LayerEntry): Promise { const { Cesium, viewer } = this; const layer = entry.layer; @@ -2378,6 +2450,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 +2474,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 +2983,11 @@ export class CesiumLayerSync { /** Fill-pattern repeat counts, by entity; see {@link patternRepeat}. */ private readonly patternRepeats = new WeakMap(); + /** + * 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 +3005,7 @@ 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") { 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..afb751ad6 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -2700,6 +2700,36 @@ def add_cesium_ion( ) ) + def add_czml( + self, + url: str | None = None, + name: str = "CZML Dynamic 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 layer to the map. + + The layer renders on the 3D globe only, animating orbits, trajectories, + models, and paths synchronized with the globe's clock. + + Args: + url: URL endpoint serving the CZML document. + name: Layer display name. + data: Inline parsed CZML document packets or packet object. + source_path: Optional local file path when loaded from disk. + **style: Style overrides. + + Returns: + The id of the added layer. + """ + 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..ea432b211 100644 --- a/python/src/geolibre/mcp/server.py +++ b/python/src/geolibre/mcp/server.py @@ -62,6 +62,7 @@ - `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 (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 +721,33 @@ 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 layer to a project. + + Renders on the 3D globe only (set the project's `primaryRenderer` to + `"cesium"`), which visualizes dynamic orbits, trajectories, vehicle + paths, and 3D scenes synchronized with the globe's clock. + + Args: + path: Path to the `.geolibre.json` file. + name: The layer's display name. + url: URL endpoint serving the CZML document. + data: Inline parsed CZML document packets or packet object. + 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..23ae40e31 100644 --- a/python/src/geolibre/project.py +++ b/python/src/geolibre/project.py @@ -1693,6 +1693,67 @@ 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 ``data`` is provided. + """ + if not url and data is None: + raise ValueError("Either url or 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 is not None: + 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], + "customLayerType": "3d-tiles", + } + 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..6ab3bd77a --- /dev/null +++ b/python/tests/test_czml.py @@ -0,0 +1,50 @@ +"""CZML dynamic 3D scene layers (issue #2290): builders, the Map API, and the MCP tool.""" + +from __future__ import annotations + +import pytest + +from geolibre import Map, 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 + assert md["customLayerType"] == "3d-tiles" + 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" + + +def test_czml_layer_requires_url_or_data(): + with pytest.raises(ValueError): + project.czml_layer("Missing") + + +def test_map_add_czml(): + m = Map() + layer_id = m.add_czml("https://example.com/orbit.czml", name="Globe Orbit") + assert isinstance(layer_id, str) + assert len(m.layers) == 1 + assert m.layers[0]["metadata"]["sourceKind"] == "czml" diff --git a/tests/czml.test.ts b/tests/czml.test.ts new file mode 100644 index 000000000..f9865f8d0 --- /dev/null +++ b/tests/czml.test.ts @@ -0,0 +1,248 @@ +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); + 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("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(); + }); +}); From f3fdc005d4836ede022376e39440361cb2d36e18 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:27:00 +0000 Subject: [PATCH 02/10] style: auto-format (ruff + oxfmt) [pre-commit.ci] --- .../layout/add-data/sources/CzmlSource.tsx | 6 +----- packages/core/src/cesium-ion.ts | 1 - packages/core/src/czml.ts | 11 +++-------- packages/map/src/cesium-layer-sync.ts | 18 +++++++++++------- python/src/geolibre/geolibre.py | 4 +--- 5 files changed, 16 insertions(+), 24 deletions(-) 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 index e16860ded..97d52a2d1 100644 --- a/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx +++ b/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx @@ -1,8 +1,4 @@ -import { - CZML_QUICK_PICKS, - createCzmlLayer, - parseCzml, -} from "@geolibre/core"; +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"; diff --git a/packages/core/src/cesium-ion.ts b/packages/core/src/cesium-ion.ts index c95d038a1..ac5605b36 100644 --- a/packages/core/src/cesium-ion.ts +++ b/packages/core/src/cesium-ion.ts @@ -53,7 +53,6 @@ export function cesiumIonAssetKind(layer: Pick): CesiumIo return layer.type === "3d-tiles" ? "3d-tiles" : "imagery"; } - /** * Whether only the 3D globe can render `layer`: the mirror of the globe's * `isCesiumSupportedLayerType`, for the Layers panel to badge on the 2D map. diff --git a/packages/core/src/czml.ts b/packages/core/src/czml.ts index d1c4e78a5..9a1adea79 100644 --- a/packages/core/src/czml.ts +++ b/packages/core/src/czml.ts @@ -74,11 +74,8 @@ export const CZML_SAMPLE_DYNAMIC: CzmlPacket[] = [ 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, + 0, -75, 40, 250000, 1800, -30, 20, 250000, 3600, 20, 0, 250000, 5400, 70, -20, 250000, 7200, + 120, -40, 250000, ], }, point: { @@ -142,9 +139,7 @@ export interface CzmlSource { /** * Extract the CZML content and/or URL from a layer, or null if not a CZML layer. */ -export function czmlSource( - layer: Pick, -): CzmlSource | null { +export function czmlSource(layer: Pick): CzmlSource | null { if (!isCzmlLayer(layer)) return null; const data = (layer.source?.czmlData ?? layer.source?.czml) as CzmlPacket[] | string | undefined; const rawUrl = layer.source?.url ?? layer.metadata?.czmlUrl; diff --git a/packages/map/src/cesium-layer-sync.ts b/packages/map/src/cesium-layer-sync.ts index cbc2a30ff..b18aea418 100644 --- a/packages/map/src/cesium-layer-sync.ts +++ b/packages/map/src/cesium-layer-sync.ts @@ -2329,13 +2329,17 @@ export class CesiumLayerSync { entry.handle = dataSource; dataSource.show = entry.layer.visible; - const dsClock = (dataSource as unknown as { clock?: { - startTime?: unknown; - stopTime?: unknown; - currentTime?: unknown; - clockRange?: unknown; - multiplier?: unknown; - } }).clock; + const dsClock = ( + dataSource as unknown as { + clock?: { + startTime?: unknown; + stopTime?: unknown; + currentTime?: unknown; + clockRange?: unknown; + multiplier?: unknown; + }; + } + ).clock; if (dsClock && viewer.clock) { if (dsClock.startTime) viewer.clock.startTime = dsClock.startTime as never; diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index afb751ad6..39473924e 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -2725,9 +2725,7 @@ def add_czml( The id of the added layer. """ return self._add_layer( - _project.czml_layer( - name, url=url, data=data, source_path=source_path, **style - ) + _project.czml_layer(name, url=url, data=data, source_path=source_path, **style) ) def add_video( From 9e917d17cfca834f876f5fafe699a68beaae139e Mon Sep 17 00:00:00 2001 From: Rohith Pariki Date: Wed, 9 Sep 2026 22:10:59 +0530 Subject: [PATCH 03/10] fix(cesium): resolve TypeScript typings, i18n keys, and test environments for CZML --- .../layout/add-data/sources/CzmlSource.tsx | 8 +++--- .../geolibre-desktop/src/i18n/locales/en.json | 3 ++ packages/core/src/czml.ts | 3 ++ packages/map/src/cesium-layer-sync.ts | 6 ++++ python/src/geolibre/geolibre.py | 28 ------------------- python/src/geolibre/mcp/server.py | 28 ------------------- python/tests/test_czml.py | 10 +------ 7 files changed, 17 insertions(+), 69 deletions(-) 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 index 97d52a2d1..63133d88e 100644 --- a/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx +++ b/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx @@ -121,8 +121,8 @@ export function CzmlSource({ initialUrl }: { initialUrl?: string }) { value={czmlMode} onChange={(event) => handleModeChange(event.target.value as CzmlMode)} > - - + + @@ -138,11 +138,11 @@ export function CzmlSource({ initialUrl }: { initialUrl?: string }) { ) : (
- +
{selectedFile?.path ?? t("addData.common.noFileSelected")} diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index a48b544a3..c48093064 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -427,6 +427,9 @@ "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.", diff --git a/packages/core/src/czml.ts b/packages/core/src/czml.ts index 9a1adea79..4e450eabe 100644 --- a/packages/core/src/czml.ts +++ b/packages/core/src/czml.ts @@ -16,6 +16,9 @@ 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[] = [ { diff --git a/packages/map/src/cesium-layer-sync.ts b/packages/map/src/cesium-layer-sync.ts index b18aea418..7acb896d1 100644 --- a/packages/map/src/cesium-layer-sync.ts +++ b/packages/map/src/cesium-layer-sync.ts @@ -822,6 +822,12 @@ 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 || + czmlSource(prev)?.data !== czmlSource(next)?.data || + str(prev.sourcePath) !== str(next.sourcePath) + ); } } diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index 39473924e..a6d567524 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -2700,34 +2700,6 @@ def add_cesium_ion( ) ) - def add_czml( - self, - url: str | None = None, - name: str = "CZML Dynamic 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 layer to the map. - - The layer renders on the 3D globe only, animating orbits, trajectories, - models, and paths synchronized with the globe's clock. - - Args: - url: URL endpoint serving the CZML document. - name: Layer display name. - data: Inline parsed CZML document packets or packet object. - source_path: Optional local file path when loaded from disk. - **style: Style overrides. - - Returns: - The id of the added layer. - """ - 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 ea432b211..a054b14d6 100644 --- a/python/src/geolibre/mcp/server.py +++ b/python/src/geolibre/mcp/server.py @@ -62,7 +62,6 @@ - `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 (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 @@ -721,33 +720,6 @@ 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 layer to a project. - - Renders on the 3D globe only (set the project's `primaryRenderer` to - `"cesium"`), which visualizes dynamic orbits, trajectories, vehicle - paths, and 3D scenes synchronized with the globe's clock. - - Args: - path: Path to the `.geolibre.json` file. - name: The layer's display name. - url: URL endpoint serving the CZML document. - data: Inline parsed CZML document packets or packet object. - 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/tests/test_czml.py b/python/tests/test_czml.py index 6ab3bd77a..e5aab846a 100644 --- a/python/tests/test_czml.py +++ b/python/tests/test_czml.py @@ -4,7 +4,7 @@ import pytest -from geolibre import Map, project +from geolibre import project def test_czml_layer_url_shape(): @@ -40,11 +40,3 @@ def test_czml_layer_data_shape(): def test_czml_layer_requires_url_or_data(): with pytest.raises(ValueError): project.czml_layer("Missing") - - -def test_map_add_czml(): - m = Map() - layer_id = m.add_czml("https://example.com/orbit.czml", name="Globe Orbit") - assert isinstance(layer_id, str) - assert len(m.layers) == 1 - assert m.layers[0]["metadata"]["sourceKind"] == "czml" From 1559d4a0191337b79d980b3f04f06c1785ad9cae Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 10 Sep 2026 14:01:42 -0400 Subject: [PATCH 04/10] Address Claude review feedback - Parse a serialized inline CZML document before handing it to CzmlDataSource.load, which treats a string as a URL to fetch; an unparseable string now surfaces as a layer error instead of a bogus fetch. - Store the inline document once under source.czmlData; the czml key is only read as a legacy fallback, so writing both doubled project size. - Drop the unused CzmlDataSource type import. - Let the first CZML document with a clock packet own the viewer clock; later CZML layers no longer reset the Time Slider's position or each other's interval, and ownership is released when that layer is removed. - Use the logical me-2 utility on the file-picker icon so it mirrors in RTL locales like the other Add Data sources. - Ship the Map.add_czml method and the add_czml_layer MCP tool the PR description promised, with tests and docs (python.md, mcp.md, agent skill references). --- .../layout/add-data/sources/CzmlSource.tsx | 2 +- docs/mcp.md | 1 + docs/python.md | 1 + packages/core/src/czml.ts | 2 +- packages/map/src/cesium-layer-sync.ts | 36 ++++++++++--- python/src/geolibre/geolibre.py | 33 ++++++++++++ python/src/geolibre/mcp/server.py | 30 +++++++++++ python/tests/test_map.py | 18 +++++++ python/tests/test_mcp_server.py | 14 +++++ skills/geolibre/references/mcp-tools.md | 2 + skills/geolibre/references/python-api.md | 1 + tests/czml.test.ts | 52 +++++++++++++++++++ 12 files changed, 184 insertions(+), 8 deletions(-) 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 index 63133d88e..91fdfcaff 100644 --- a/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx +++ b/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx @@ -141,7 +141,7 @@ export function CzmlSource({ initialUrl }: { initialUrl?: string }) {
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/czml.ts b/packages/core/src/czml.ts index 4e450eabe..6288b2a6e 100644 --- a/packages/core/src/czml.ts +++ b/packages/core/src/czml.ts @@ -193,7 +193,7 @@ export function createCzmlLayer(options: CzmlLayerOptions): GeoLibreLayer { source: { type: "3d-tiles", sourceId: id, - ...(data !== undefined ? { czmlData: data, czml: data } : {}), + ...(data !== undefined ? { czmlData: data } : {}), ...(url ? { url } : {}), ...(sourcePath ? { sourcePath } : {}), }, diff --git a/packages/map/src/cesium-layer-sync.ts b/packages/map/src/cesium-layer-sync.ts index 7acb896d1..5b30cff7b 100644 --- a/packages/map/src/cesium-layer-sync.ts +++ b/packages/map/src/cesium-layer-sync.ts @@ -7,6 +7,7 @@ import { geojsonHasZCoordinates, getCesiumIonToken, isCzmlLayer, + parseCzml, resolveThreeDTilesRequestHeaders, ruleBasedVisibilityFilter, transformGeojsonElevation, @@ -68,7 +69,6 @@ import type { Cesium3DTileset, CesiumWidget, Color, - CzmlDataSource, DataSource, DistanceDisplayCondition, Entity, @@ -2316,8 +2316,16 @@ export class CesiumLayerSync { /** * Load a CZML (Cesium Language) document as a dynamic 3D scene (issue #2290). - * Supports URL endpoints or inline parsed CZML document packets with dynamic - * time-tagged positions, orbits, models, paths, and clock synchronization. + * 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. + * + * The globe has a single clock, so only the first CZML layer whose document + * carries a `clock` packet adopts it (start/stop/current time, range, + * multiplier). Later CZML layers render against that clock without resetting + * it, and ownership is released when the owning layer is removed so the next + * loaded document can take over. The Time Slider keeps driving `currentTime` + * through {@link setTime} either way. * * @param entry The synchronizer entry tracking this CZML layer. */ @@ -2325,11 +2333,18 @@ export class CesiumLayerSync { const { Cesium, viewer } = this; const source = czmlSource(entry.layer); if (!source) return; - const target = source.data ?? source.url; + // `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 as string | object); + const dataSource = await Cesium.CzmlDataSource.load(target); if (entry.cancelled) return; entry.handle = dataSource; @@ -2347,7 +2362,9 @@ export class CesiumLayerSync { } ).clock; - if (dsClock && viewer.clock) { + const ownsClock = this.czmlClockOwner === undefined || this.czmlClockOwner === entry.layer.id; + if (dsClock && viewer.clock && ownsClock) { + this.czmlClockOwner = entry.layer.id; if (dsClock.startTime) viewer.clock.startTime = dsClock.startTime as never; if (dsClock.stopTime) viewer.clock.stopTime = dsClock.stopTime as never; if (dsClock.currentTime) viewer.clock.currentTime = dsClock.currentTime as never; @@ -2993,6 +3010,12 @@ 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 createCzml}. Cleared when that layer is destroyed. + */ + private czmlClockOwner: string | undefined; + /** * Tear down an entry and release its Cesium resources from the scene. * @@ -3016,6 +3039,7 @@ export class CesiumLayerSync { this.viewer.imageryLayers.remove(imagery, true); if (provider instanceof ProtocolImageryProvider) provider.destroy(); } else if (entry.kind === "geojson" || entry.kind === "czml") { + if (this.czmlClockOwner === entry.layer.id) this.czmlClockOwner = undefined; 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..fd878484a 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,34 @@ 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]] | 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 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/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..9947840e0 100644 --- a/python/tests/test_mcp_server.py +++ b/python/tests/test_mcp_server.py @@ -444,6 +444,8 @@ 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_tiles_layer", {"url": "https://example.com/a.pmtiles", "kind": "pmtiles"}, @@ -488,6 +490,18 @@ 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 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 index f9865f8d0..07a3a1564 100644 --- a/tests/czml.test.ts +++ b/tests/czml.test.ts @@ -69,6 +69,8 @@ describe("czml layer builder & parser", () => { }); 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); @@ -224,6 +226,56 @@ describe("CesiumLayerSync with CZML", () => { 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("lets the first CZML document own the viewer clock until it is removed", async () => { + const { Cesium, viewer } = makeGlobe(); + 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" }); + + sync.sync([a]); + for (let i = 0; i < 4; i++) await flush(); + assert.equal(viewer.clock.multiplier, 60); + + // The user (or the Time Slider) moved the clock; a second document must + // not stomp it. + viewer.clock.multiplier = 5; + viewer.clock.currentTime = "scrubbed"; + sync.sync([a, b]); + for (let i = 0; i < 4; i++) await flush(); + assert.equal(viewer.clock.multiplier, 5); + assert.equal(viewer.clock.currentTime, "scrubbed"); + + // Removing the owner releases the clock to the next document that loads. + sync.sync([b]); + for (let i = 0; i < 4; i++) await flush(); + const c = createCzmlLayer({ id: "czml-c", name: "C", url: "https://example.com/c.czml" }); + sync.sync([b, c]); + for (let i = 0; i < 4; i++) await flush(); + assert.equal(viewer.clock.multiplier, 60); + sync.destroy(); + }); + it("handles load errors gracefully and reports in getRenderStatus", async () => { const { Cesium, viewer } = makeGlobe(); Cesium.CzmlDataSource.load = async () => { From 881c941bc0a53adda48a0929ba667caf0e723417 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 10 Sep 2026 14:11:51 -0400 Subject: [PATCH 05/10] Address Claude review feedback - Hide the tileset symbology and quick-filter sections of the Style panel for CZML scenes: the globe sync only toggles their visibility, so those controls were silent no-ops; the footer now reads "czml" rather than "3d-tiles". - Drop metadata.customLayerType from the Python czml_layer builder so it matches createCzmlLayer and the Layer Library treats CZML layers the same regardless of which API authored them. --- .../src/components/panels/StylePanel.tsx | 12 ++++++++++-- python/src/geolibre/project.py | 1 - python/tests/test_czml.py | 4 +++- 3 files changed, 13 insertions(+), 4 deletions(-) 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/python/src/geolibre/project.py b/python/src/geolibre/project.py index 23ae40e31..e3f455883 100644 --- a/python/src/geolibre/project.py +++ b/python/src/geolibre/project.py @@ -1747,7 +1747,6 @@ def czml_layer( "identifiable": False, "sourceId": source_id, "nativeLayerIds": [source_id], - "customLayerType": "3d-tiles", } layer["source"] = source layer["metadata"] = metadata diff --git a/python/tests/test_czml.py b/python/tests/test_czml.py index e5aab846a..673146494 100644 --- a/python/tests/test_czml.py +++ b/python/tests/test_czml.py @@ -19,7 +19,9 @@ def test_czml_layer_url_shape(): assert md["sourceKind"] == "czml" assert md["externalNativeLayer"] is True assert md["identifiable"] is False - assert md["customLayerType"] == "3d-tiles" + # 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 From 59538dbe0c55b40d25c9f6ffd3d0a4b2ff991f80 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 10 Sep 2026 14:23:30 -0400 Subject: [PATCH 06/10] Address review feedback - Elect the CZML clock owner deterministically: the first layer in synced order whose loaded document carries a clock packet, re-elected (and its clock re-applied without a reload) when the owner is removed. Tests cover out-of-order loads and removal with an already-loaded fallback. - Let the add_czml_layer MCP tool accept a single packet dict as well as a packet list, matching Map.add_czml and czml_layer. - Treat an empty packet array as no document in czmlSource, and reject empty data in the Python builder, so a layer can never look ready with nothing to load. - Wrap the Add Data quick pick in the same error handling as the form submit and clone the sample packets so layers never share the exported constant. - Drop the unused czml option alias from createCzmlLayer. --- .../layout/add-data/sources/CzmlSource.tsx | 19 +++-- packages/core/src/czml.ts | 9 +- packages/map/src/cesium-layer-sync.ts | 85 ++++++++++++------- python/src/geolibre/mcp/server.py | 7 +- python/src/geolibre/project.py | 6 +- python/tests/test_czml.py | 8 +- python/tests/test_mcp_server.py | 5 +- tests/czml.test.ts | 55 +++++++++--- 8 files changed, 130 insertions(+), 64 deletions(-) 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 index 91fdfcaff..1ef17eafc 100644 --- a/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx +++ b/apps/geolibre-desktop/src/components/layout/add-data/sources/CzmlSource.tsx @@ -92,15 +92,22 @@ export function CzmlSource({ initialUrl }: { initialUrl?: string }) { ); }); + // 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); - source.addAndClose( - createCzmlLayer({ - name: pick.name, - data: pick.data, - }), - ); + try { + source.addAndClose( + createCzmlLayer({ + name: pick.name, + data: structuredClone(pick.data), + }), + ); + } catch (err) { + source.setError(errorMessage(err, t("addData.shared.addError"))); + } }; return ( diff --git a/packages/core/src/czml.ts b/packages/core/src/czml.ts index 6288b2a6e..88e0b78a2 100644 --- a/packages/core/src/czml.ts +++ b/packages/core/src/czml.ts @@ -144,7 +144,10 @@ export interface CzmlSource { */ export function czmlSource(layer: Pick): CzmlSource | null { if (!isCzmlLayer(layer)) return null; - const data = (layer.source?.czmlData ?? layer.source?.czml) as CzmlPacket[] | string | undefined; + // `czml` is a legacy key read for hand-authored projects; only `czmlData` is written. + const raw = (layer.source?.czmlData ?? layer.source?.czml) as CzmlPacket[] | string | undefined; + // An empty packet array is a document with nothing in it, not a document. + const data = Array.isArray(raw) ? (raw.length > 0 ? raw : undefined) : raw || undefined; const rawUrl = layer.source?.url ?? layer.metadata?.czmlUrl; const url = typeof rawUrl === "string" && rawUrl.trim() ? rawUrl.trim() : undefined; if (!data && !url) return null; @@ -167,8 +170,6 @@ export interface CzmlLayerOptions { /** Display name of the layer in the Layers panel. */ name: string; /** Inlined CZML packet array or serialized JSON string. */ - czml?: CzmlPacket[] | string; - /** Inlined CZML packet array or serialized JSON string (alias for czml). */ data?: CzmlPacket[] | string; /** Remote URL pointing to a .czml document. */ url?: string; @@ -181,7 +182,7 @@ export interface CzmlLayerOptions { */ export function createCzmlLayer(options: CzmlLayerOptions): GeoLibreLayer { const id = options.id ?? newLayerId(); - const data = options.data ?? options.czml; + const data = options.data; const url = options.url?.trim() || undefined; const sourcePath = options.sourcePath?.trim() || undefined; diff --git a/packages/map/src/cesium-layer-sync.ts b/packages/map/src/cesium-layer-sync.ts index 5b30cff7b..299091aac 100644 --- a/packages/map/src/cesium-layer-sync.ts +++ b/packages/map/src/cesium-layer-sync.ts @@ -180,6 +180,20 @@ const ARCGIS_MAP_SERVICE_KIND = "arcgis-map-service"; 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 { featuresLength?: number; @@ -1239,6 +1253,8 @@ export class CesiumLayerSync { 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(); @@ -2318,14 +2334,8 @@ 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. - * - * The globe has a single clock, so only the first CZML layer whose document - * carries a `clock` packet adopts it (start/stop/current time, range, - * multiplier). Later CZML layers render against that clock without resetting - * it, and ownership is released when the owning layer is removed so the next - * loaded document can take over. The Time Slider keeps driving `currentTime` - * through {@link setTime} either way. + * and clock synchronization. Which document drives the viewer clock is + * decided by {@link electCzmlClockOwner}. * * @param entry The synchronizer entry tracking this CZML layer. */ @@ -2349,28 +2359,7 @@ export class CesiumLayerSync { entry.handle = dataSource; dataSource.show = entry.layer.visible; - - const dsClock = ( - dataSource as unknown as { - clock?: { - startTime?: unknown; - stopTime?: unknown; - currentTime?: unknown; - clockRange?: unknown; - multiplier?: unknown; - }; - } - ).clock; - - const ownsClock = this.czmlClockOwner === undefined || this.czmlClockOwner === entry.layer.id; - if (dsClock && viewer.clock && ownsClock) { - this.czmlClockOwner = entry.layer.id; - if (dsClock.startTime) viewer.clock.startTime = dsClock.startTime as never; - if (dsClock.stopTime) viewer.clock.stopTime = dsClock.stopTime as never; - if (dsClock.currentTime) viewer.clock.currentTime = dsClock.currentTime as never; - if (dsClock.clockRange !== undefined) viewer.clock.clockRange = dsClock.clockRange as never; - if (dsClock.multiplier !== undefined) viewer.clock.multiplier = dsClock.multiplier as never; - } + this.electCzmlClockOwner(); await viewer.dataSources.add(dataSource); if (entry.cancelled) { @@ -2385,6 +2374,37 @@ export class CesiumLayerSync { } } + /** + * 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}. + */ + 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 || !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; @@ -3012,7 +3032,7 @@ export class CesiumLayerSync { /** * Id of the CZML layer whose document `clock` the viewer clock follows; see - * {@link createCzml}. Cleared when that layer is destroyed. + * {@link electCzmlClockOwner}. Re-elected when that layer is destroyed. */ private czmlClockOwner: string | undefined; @@ -3039,7 +3059,8 @@ export class CesiumLayerSync { this.viewer.imageryLayers.remove(imagery, true); if (provider instanceof ProtocolImageryProvider) provider.destroy(); } else if (entry.kind === "geojson" || entry.kind === "czml") { - if (this.czmlClockOwner === entry.layer.id) this.czmlClockOwner = undefined; + // `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/mcp/server.py b/python/src/geolibre/mcp/server.py index fd878484a..b2b833910 100644 --- a/python/src/geolibre/mcp/server.py +++ b/python/src/geolibre/mcp/server.py @@ -727,7 +727,7 @@ def add_czml_layer( path: str, name: str, url: str | None = None, - data: list[dict[str, Any]] | 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. @@ -740,8 +740,9 @@ def add_czml_layer( 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 to inline instead of a URL; the first - packet is normally `{"id": "document", "version": "1.0"}`. + 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: diff --git a/python/src/geolibre/project.py b/python/src/geolibre/project.py index e3f455883..a18690e2a 100644 --- a/python/src/geolibre/project.py +++ b/python/src/geolibre/project.py @@ -1723,10 +1723,10 @@ def czml_layer( A layer dict for the project's ``layers`` array. Raises: - ValueError: If neither ``url`` nor ``data`` is provided. + ValueError: If neither ``url`` nor a non-empty ``data`` is provided. """ - if not url and data is None: - raise ValueError("Either url or data must be provided for a CZML layer") + 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] = { diff --git a/python/tests/test_czml.py b/python/tests/test_czml.py index 673146494..18e4eb936 100644 --- a/python/tests/test_czml.py +++ b/python/tests/test_czml.py @@ -39,6 +39,8 @@ def test_czml_layer_data_shape(): assert layer["metadata"]["sourceKind"] == "czml" -def test_czml_layer_requires_url_or_data(): - with pytest.raises(ValueError): - project.czml_layer("Missing") +@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_mcp_server.py b/python/tests/test_mcp_server.py index 9947840e0..4d2425980 100644 --- a/python/tests/test_mcp_server.py +++ b/python/tests/test_mcp_server.py @@ -446,6 +446,7 @@ def test_add_raster_layer_records_its_source(server, project_path): ("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"}, @@ -499,7 +500,9 @@ def test_czml_tool_persists_the_document(server, project_path, tmp_path): 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 data" in call_error(server, "add_czml_layer", path=project_path, name="C") + 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): diff --git a/tests/czml.test.ts b/tests/czml.test.ts index 07a3a1564..357fda7e6 100644 --- a/tests/czml.test.ts +++ b/tests/czml.test.ts @@ -247,32 +247,63 @@ describe("CesiumLayerSync with CZML", () => { sync.destroy(); }); - it("lets the first CZML document own the viewer clock until it is removed", async () => { - const { Cesium, viewer } = makeGlobe(); + 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]); + sync.sync([a, b]); + await new Promise((r) => setTimeout(r, 40)); for (let i = 0; i < 4; i++) await flush(); - assert.equal(viewer.clock.multiplier, 60); + // 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 second document must - // not stomp it. + // 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]); + 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 releases the clock to the next document that loads. - sync.sync([b]); - for (let i = 0; i < 4; i++) await flush(); - const c = createCzmlLayer({ id: "czml-c", name: "C", url: "https://example.com/c.czml" }); + // 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, 60); + assert.equal(viewer.clock.multiplier, 20); + assert.equal(viewer.clock.currentTime, "t-https://example.com/b.czml"); + 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(); }); From 8a45cf5d07111a1c196f6a883d9936d57d259b5a Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 10 Sep 2026 14:33:13 -0400 Subject: [PATCH 07/10] Address review feedback - Elect the CZML clock owner only once the data source has joined the scene, so a rejected add never leaves a non-rendering layer driving the viewer clock; regression test added. - Skip writing an empty czmlData alongside a URL in the Python builder. --- packages/map/src/cesium-layer-sync.ts | 6 ++++-- python/src/geolibre/project.py | 2 +- tests/czml.test.ts | 13 +++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/map/src/cesium-layer-sync.ts b/packages/map/src/cesium-layer-sync.ts index 299091aac..5ec5d918f 100644 --- a/packages/map/src/cesium-layer-sync.ts +++ b/packages/map/src/cesium-layer-sync.ts @@ -2359,7 +2359,6 @@ export class CesiumLayerSync { entry.handle = dataSource; dataSource.show = entry.layer.visible; - this.electCzmlClockOwner(); await viewer.dataSources.add(dataSource); if (entry.cancelled) { @@ -2367,6 +2366,8 @@ export class CesiumLayerSync { 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; @@ -2389,7 +2390,8 @@ export class CesiumLayerSync { let owner: LayerEntry | undefined; for (const layer of this.currentLayers) { const entry = this.entries.get(layer.id); - if (entry?.kind !== "czml" || entry.cancelled || !czmlDocumentClock(entry.handle)) continue; + if (entry?.kind !== "czml" || entry.cancelled || !entry.added) continue; + if (!czmlDocumentClock(entry.handle)) continue; owner = entry; break; } diff --git a/python/src/geolibre/project.py b/python/src/geolibre/project.py index a18690e2a..ccfe3268b 100644 --- a/python/src/geolibre/project.py +++ b/python/src/geolibre/project.py @@ -1735,7 +1735,7 @@ def czml_layer( } if url: source["url"] = url - if data is not None: + if data: source["czmlData"] = data if source_path: source["sourcePath"] = source_path diff --git a/tests/czml.test.ts b/tests/czml.test.ts index 357fda7e6..ac0bb0c8d 100644 --- a/tests/czml.test.ts +++ b/tests/czml.test.ts @@ -307,6 +307,19 @@ describe("CesiumLayerSync with CZML", () => { 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 () => { From 9a5ada9ba97a152003f2eec8903afeb310280824 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 10 Sep 2026 15:25:44 -0400 Subject: [PATCH 08/10] Address review feedback - Re-elect the CZML clock owner at the end of sync(), so reordering two already-loaded clocked documents hands the clock to the new first one; test extended. - Drop the never-written source.czml and metadata.czmlUrl fallbacks from czmlSource/createCzmlLayer; nothing produces either key. --- packages/core/src/czml.ts | 6 ++---- packages/map/src/cesium-layer-sync.ts | 2 ++ tests/czml.test.ts | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/core/src/czml.ts b/packages/core/src/czml.ts index 88e0b78a2..f7397a8d4 100644 --- a/packages/core/src/czml.ts +++ b/packages/core/src/czml.ts @@ -144,11 +144,10 @@ export interface CzmlSource { */ export function czmlSource(layer: Pick): CzmlSource | null { if (!isCzmlLayer(layer)) return null; - // `czml` is a legacy key read for hand-authored projects; only `czmlData` is written. - const raw = (layer.source?.czmlData ?? layer.source?.czml) as CzmlPacket[] | string | undefined; + const raw = layer.source?.czmlData as CzmlPacket[] | string | undefined; // An empty packet array is a document with nothing in it, not a document. const data = Array.isArray(raw) ? (raw.length > 0 ? raw : undefined) : raw || undefined; - const rawUrl = layer.source?.url ?? layer.metadata?.czmlUrl; + const rawUrl = layer.source?.url; const url = typeof rawUrl === "string" && rawUrl.trim() ? rawUrl.trim() : undefined; if (!data && !url) return null; return { url, data }; @@ -207,7 +206,6 @@ export function createCzmlLayer(options: CzmlLayerOptions): GeoLibreLayer { identifiable: false, sourceId: id, nativeLayerIds: [id], - ...(url ? { czmlUrl: url } : {}), }, }; } diff --git a/packages/map/src/cesium-layer-sync.ts b/packages/map/src/cesium-layer-sync.ts index 5ec5d918f..3107bbc95 100644 --- a/packages/map/src/cesium-layer-sync.ts +++ b/packages/map/src/cesium-layer-sync.ts @@ -1248,6 +1248,8 @@ export class CesiumLayerSync { } this.applyHighlight(); this.watchCameraZoom(); + // Reordering two loaded CZML layers changes which one comes first. + this.electCzmlClockOwner(); } destroy(): void { diff --git a/tests/czml.test.ts b/tests/czml.test.ts index ac0bb0c8d..3cb3cc7ab 100644 --- a/tests/czml.test.ts +++ b/tests/czml.test.ts @@ -301,6 +301,12 @@ describe("CesiumLayerSync with CZML", () => { 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); From eccb4ed8cad73c0739a5f46b617c7fb74196907d Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 10 Sep 2026 15:35:08 -0400 Subject: [PATCH 09/10] Address Claude review feedback - Wrap a bare packet object in czmlSource() the way parseCzml does, so a single-packet document from the Python API or MCP tool reaches Cesium as an array instead of rendering nothing; test added. - Document that the viewer clock stays where the last CZML document left it once no CZML layer remains, since the Time Slider owns time then. --- packages/core/src/czml.ts | 14 +++++++++++--- packages/map/src/cesium-layer-sync.ts | 4 +++- tests/czml.test.ts | 7 +++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/core/src/czml.ts b/packages/core/src/czml.ts index f7397a8d4..3e4f6024c 100644 --- a/packages/core/src/czml.ts +++ b/packages/core/src/czml.ts @@ -144,9 +144,17 @@ export interface CzmlSource { */ export function czmlSource(layer: Pick): CzmlSource | null { if (!isCzmlLayer(layer)) return null; - const raw = layer.source?.czmlData as CzmlPacket[] | string | undefined; - // An empty packet array is a document with nothing in it, not a document. - const data = Array.isArray(raw) ? (raw.length > 0 ? raw : undefined) : raw || undefined; + 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; diff --git a/packages/map/src/cesium-layer-sync.ts b/packages/map/src/cesium-layer-sync.ts index 3107bbc95..0e5071cf9 100644 --- a/packages/map/src/cesium-layer-sync.ts +++ b/packages/map/src/cesium-layer-sync.ts @@ -2386,7 +2386,9 @@ export class CesiumLayerSync { * 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}. + * {@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; diff --git a/tests/czml.test.ts b/tests/czml.test.ts index 3cb3cc7ab..e92a36438 100644 --- a/tests/czml.test.ts +++ b/tests/czml.test.ts @@ -247,6 +247,13 @@ describe("CesiumLayerSync with CZML", () => { sync.destroy(); }); + it("wraps a bare packet from the Python API into a document array", () => { + 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]); + }); + it("treats an empty packet array as no document", () => { const layer = createCzmlLayer({ id: "czml-empty", name: "Empty", data: [] }); assert.equal(czmlSource(layer), null); From 1c3b93074b7f4b610ae5b0fce8ff3fe487c38bd0 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 10 Sep 2026 15:58:27 -0400 Subject: [PATCH 10/10] Address Claude review feedback - Compare the raw source.czmlData reference in needsRebuild instead of czmlSource().data, which wraps a bare packet in a fresh array per call and would have rebuilt such a layer on every unrelated sync; test asserts a spread layer does not reload. --- packages/map/src/cesium-layer-sync.ts | 4 +++- tests/czml.test.ts | 13 ++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/map/src/cesium-layer-sync.ts b/packages/map/src/cesium-layer-sync.ts index 0e5071cf9..fec2663d0 100644 --- a/packages/map/src/cesium-layer-sync.ts +++ b/packages/map/src/cesium-layer-sync.ts @@ -839,7 +839,9 @@ function needsRebuild(prev: GeoLibreLayer, next: GeoLibreLayer): boolean { case "czml": return ( czmlSource(prev)?.url !== czmlSource(next)?.url || - czmlSource(prev)?.data !== czmlSource(next)?.data || + // 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) ); } diff --git a/tests/czml.test.ts b/tests/czml.test.ts index e92a36438..e217a90f2 100644 --- a/tests/czml.test.ts +++ b/tests/czml.test.ts @@ -247,11 +247,22 @@ describe("CesiumLayerSync with CZML", () => { sync.destroy(); }); - it("wraps a bare packet from the Python API into a document array", () => { + 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", () => {