diff --git a/apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx b/apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx index 1eb61c9d43..7a8c0acefb 100644 --- a/apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx @@ -10,6 +10,7 @@ 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 { KmlSource } from "./add-data/sources/KmlSource"; import { DeckVizSource } from "./add-data/sources/DeckVizSource"; import { DelimitedTextSource } from "./add-data/sources/DelimitedTextSource"; import { GdbSource } from "./add-data/sources/GdbSource"; @@ -87,6 +88,8 @@ function renderSource( return ; case "czml": return ; + case "kml": + 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 f39d4b4e8a..636a0cffc0 100644 --- a/apps/geolibre-desktop/src/components/layout/add-data/constants.ts +++ b/apps/geolibre-desktop/src/components/layout/add-data/constants.ts @@ -33,7 +33,8 @@ export type KindI18nKey = | "deckglViz" | "video" | "cesiumIon" - | "czml"; + | "czml" + | "kml"; /** * Maps each Add Data kind to its `addData.kind.` i18n segment. The dialog @@ -63,6 +64,7 @@ export const KIND_I18N_KEY: Record = { video: "video", "cesium-ion": "cesiumIon", czml: "czml", + kml: "kml", }; export const DEFAULT_XYZ_URL = diff --git a/apps/geolibre-desktop/src/components/layout/add-data/sources/KmlSource.tsx b/apps/geolibre-desktop/src/components/layout/add-data/sources/KmlSource.tsx new file mode 100644 index 0000000000..83f7272056 --- /dev/null +++ b/apps/geolibre-desktop/src/components/layout/add-data/sources/KmlSource.tsx @@ -0,0 +1,94 @@ +import { createCesiumKmlLayer } from "@geolibre/core"; +import { Button, Input, Label } from "@geolibre/ui"; +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"; + +/** Preserve the original KML document or KMZ archive in the project. */ +export function KmlSource({ initialUrl }: { initialUrl?: string }) { + const { t } = useTranslation(); + const [defaultName] = useState(() => t("addData.kml.defaultName")); + const source = useAddDataSource(defaultName); + const [url, setUrl] = useState(initialUrl ?? ""); + const [file, setFile] = useState<{ path: string; data: string } | null>(null); + const chooseFile = async () => { + source.setError(null); + try { + const picked = await openLocalDataFileWithFallback({ + filters: [{ name: "KML / KMZ", extensions: ["kml", "kmz"] }], + accept: ".kml,.kmz", + readText: true, + binaryExtensions: ["kmz"], + }); + if (!picked) return; + let data = picked.text ?? ""; + if (picked.data) { + data = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL( + new Blob([picked.data!], { type: "application/vnd.google-earth.kmz" }), + ); + }); + } + if (!data.trim()) throw new Error(t("addData.kml.errorSource")); + setFile({ path: picked.path, data }); + setUrl(""); + source.setLayerName((current) => + current.trim() && current !== defaultName + ? current + : layerNameFromPath(picked.path, defaultName), + ); + } catch (error) { + source.setError(errorMessage(error, t("addData.shared.addError"))); + } + }; + const submit = source.runSubmit(() => { + if (!file && !url.trim()) throw new Error(t("addData.kml.errorSource")); + source.addAndClose( + createCesiumKmlLayer({ + name: source.layerName.trim() || defaultName, + url: url.trim(), + data: file?.data, + sourcePath: file?.path, + }), + ); + }); + return ( + +
+
+ + { + setUrl(event.target.value); + setFile(null); + }} + /> +
+
+ + + {file?.path ?? t("addData.common.noFileSelected")} + +
+
+
+ ); +} 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 c2e985d085..a7d809ab91 100644 --- a/apps/geolibre-desktop/src/components/layout/add-data/types.ts +++ b/apps/geolibre-desktop/src/components/layout/add-data/types.ts @@ -24,7 +24,8 @@ export type AddDataKind = | "deckgl-viz" | "video" | "cesium-ion" - | "czml"; + | "czml" + | "kml"; /** 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 a2e98c442e..37b96a126d 100644 --- a/apps/geolibre-desktop/src/components/layout/toolbar/AddDataMenu.tsx +++ b/apps/geolibre-desktop/src/components/layout/toolbar/AddDataMenu.tsx @@ -104,6 +104,7 @@ export function AddDataMenu({ "cesium-ion": { onSelect: () => onSetAddDataKind("cesium-ion"), disabled: !cesiumPrimary }, // CZML dynamic 3D scenes load through Cesium only (issue #2290). czml: { onSelect: () => onSetAddDataKind("czml"), disabled: !cesiumPrimary }, + kml: { onSelect: () => onSetAddDataKind("kml"), disabled: !cesiumPrimary }, // The glTF model opens the same deck.gl scenegraph builder, so it is // gated the way "deckgl-viz" is. "gltf-model": { onSelect: onAddGltfModel, disabled: !capabilities.customLayers }, diff --git a/apps/geolibre-desktop/src/components/panels/LayerPanel.tsx b/apps/geolibre-desktop/src/components/panels/LayerPanel.tsx index 74af0ecc5d..538ebef7cf 100644 --- a/apps/geolibre-desktop/src/components/panels/LayerPanel.tsx +++ b/apps/geolibre-desktop/src/components/panels/LayerPanel.tsx @@ -3551,9 +3551,16 @@ export function LayerPanel({ {layerTypeLabel(layer, t)} - {isPlaceholderLayer(layer) && ( -

{placeholderMessage(layer)}

- )} + {/* Placeholder detection checks MapLibre source ids, which the + globe's own layers never create. Suppress it only for the + kinds Cesium actually draws — a kind it cannot draw (e.g. + duckdb-query) keeps its message while the globe is primary. */} + {(!cesiumPrimary || !isCesiumSupportedLayerType(layer)) && + isPlaceholderLayer(layer) && ( +

+ {placeholderMessage(layer)} +

+ )} {refreshStatus && (

- {t("style.selectedLayerType", { type: isCzmlScene ? "czml" : layer.type })} + {t("style.selectedLayerType", { + type: isCesiumKmlLayer(layer) + ? "KML / KMZ" + : isNativeDocumentScene + ? "czml" + : layer.type, + })}

); diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 37e2a50549..a2208a708c 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -355,6 +355,10 @@ "polyline": { "label": "إضافة طبقة خط متعدد مشفر", "description": "استيراد سلاسل الخطوط المتعددة المشفرة (Google وOSRM وValhalla) من نص أو ملفات كخطوط متجهة." + }, + "kml": { + "label": "KML / KMZ", + "description": "حمّل KML أو KMZ مع الأنماط والتراكبات وروابط الشبكة الأصلية على الكرة الأرضية." } }, "shared": { @@ -420,6 +424,11 @@ "importedWithDropped_many": "تم استيراد {{count}} خدمةً (تم تخطي {{dropped}} — المكتبة ممتلئة).", "importedWithDropped_other": "تم استيراد {{count}} خدمة (تم تخطي {{dropped}} — المكتبة ممتلئة)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "عنوان URL لملف KML / KMZ", + "errorSource": "أدخل رابط KML/KMZ أو اختر ملفًا." + }, "xyz": { "defaultName": "طبقة XYZ", "sampleLabel": "مرئيات USGS", diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index 35cef7e084..dd83ae3bd5 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -347,6 +347,10 @@ "polyline": { "label": "Kodierte Polyline-Ebene hinzufügen", "description": "Kodierte Polyline-Zeichenfolgen (Google, OSRM, Valhalla) aus Text oder Dateien als Vektorlinien importieren." + }, + "kml": { + "label": "KML / KMZ", + "description": "KML oder KMZ mit nativen Stilen, Overlays und Netzwerklinks auf dem Globus laden." } }, "shared": { @@ -404,6 +408,11 @@ "importedWithDropped_one": "{{count}} Dienst importiert ({{dropped}} übersprungen — Bibliothek voll).", "importedWithDropped_other": "{{count}} Dienste importiert ({{dropped}} übersprungen — Bibliothek voll)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "KML-/KMZ-URL", + "errorSource": "Geben Sie eine KML/KMZ-URL ein oder wählen Sie eine Datei." + }, "xyz": { "defaultName": "XYZ-Ebene", "sampleLabel": "USGS-Luftbilder", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 7092209e81..9c0fc0dc3f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -355,6 +355,10 @@ "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." + }, + "kml": { + "label": "KML / KMZ", + "description": "Load KML or KMZ with native globe styling, overlays, and network links." } }, "shared": { @@ -438,6 +442,11 @@ "errorInvalidCzml": "The file does not contain a valid CZML document (expected JSON array or packet object).", "readError": "Failed to read CZML data." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "KML / KMZ URL", + "errorSource": "Enter a KML/KMZ URL or choose a file." + }, "xyz": { "defaultName": "XYZ Layer", "sampleLabel": "USGS imagery", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 4f2790b875..5166240455 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -347,6 +347,10 @@ "polyline": { "label": "Añadir capa de polilínea codificada", "description": "Importar cadenas de polilínea codificada (Google, OSRM, Valhalla) desde texto o archivos como líneas vectoriales." + }, + "kml": { + "label": "KML / KMZ", + "description": "Carga KML o KMZ con estilos nativos, superposiciones y enlaces de red en el globo." } }, "shared": { @@ -404,6 +408,11 @@ "importedWithDropped_one": "Se importó {{count}} servicio ({{dropped}} omitido: biblioteca llena).", "importedWithDropped_other": "Se importaron {{count}} servicios ({{dropped}} omitidos: biblioteca llena)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "URL de KML / KMZ", + "errorSource": "Introduce una URL KML/KMZ o elige un archivo." + }, "xyz": { "defaultName": "Capa XYZ", "sampleLabel": "Imágenes de USGS", diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index a9ab6982c3..a42a16f5d0 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -347,6 +347,10 @@ "polyline": { "label": "افزودن لایهٔ چندخطی کدگذاری‌شده", "description": "وارد کردن رشته‌های چندخطی کدگذاری‌شده (Google، OSRM، Valhalla) از متن یا فایل‌ها به‌صورت خطوط برداری." + }, + "kml": { + "label": "KML / KMZ", + "description": "KML یا KMZ را با سبک‌های بومی، هم‌پوشانی‌ها و پیوندهای شبکه روی کره بارگذاری کنید." } }, "shared": { @@ -404,6 +408,11 @@ "importedWithDropped_one": "{{count}} سرویس وارد شد ({{dropped}} مورد رد شد — کتابخانه پر است).", "importedWithDropped_other": "{{count}} سرویس وارد شد ({{dropped}} مورد رد شد — کتابخانه پر است)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "نشانی KML / KMZ", + "errorSource": "نشانی KML/KMZ را وارد کنید یا یک فایل انتخاب کنید." + }, "xyz": { "defaultName": "لایهٔ XYZ", "sampleLabel": "تصاویر USGS", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 9d835bca45..9baf145fc7 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -347,6 +347,10 @@ "polyline": { "label": "Ajouter une couche de polyligne encodée", "description": "Importer des chaînes de polylignes encodées (Google, OSRM, Valhalla) depuis du texte ou des fichiers sous forme de lignes vectorielles." + }, + "kml": { + "label": "KML / KMZ", + "description": "Chargez un KML ou KMZ avec ses styles natifs, superpositions et liens réseau sur le globe." } }, "shared": { @@ -404,6 +408,11 @@ "importedWithDropped_one": "{{count}} service importé ({{dropped}} ignoré — bibliothèque pleine).", "importedWithDropped_other": "{{count}} services importés ({{dropped}} ignorés — bibliothèque pleine)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "URL KML / KMZ", + "errorSource": "Saisissez une URL KML/KMZ ou choisissez un fichier." + }, "xyz": { "defaultName": "Couche XYZ", "sampleLabel": "Imagerie USGS", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index e08a3d4fb6..5c40d71254 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -347,6 +347,10 @@ "polyline": { "label": "एन्कोडेड पॉलीलाइन लेयर जोड़ें", "description": "एन्कोडेड पॉलीलाइन स्ट्रिंग (Google, OSRM, Valhalla) को टेक्स्ट या फ़ाइलों से वेक्टर लाइनों के रूप में आयात करें।" + }, + "kml": { + "label": "KML / KMZ", + "description": "ग्लोब पर मूल शैलियों, ओवरले और नेटवर्क लिंक के साथ KML या KMZ लोड करें।" } }, "shared": { @@ -404,6 +408,11 @@ "importedWithDropped_one": "{{count}} सेवा आयात की गई ({{dropped}} छोड़ी गई — लाइब्रेरी भर गई)।", "importedWithDropped_other": "{{count}} सेवाएँ आयात की गईं ({{dropped}} छोड़ी गईं — लाइब्रेरी भर गई)।" }, + "kml": { + "defaultName": "KML / KMZ", + "url": "KML / KMZ URL", + "errorSource": "KML/KMZ URL दर्ज करें या फ़ाइल चुनें।" + }, "xyz": { "defaultName": "XYZ लेयर", "sampleLabel": "USGS इमेजरी", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index 95f991e41f..8a99201c26 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -345,6 +345,10 @@ "polyline": { "label": "Tambahkan Layer Polyline Terenkode", "description": "Impor string polyline terenkode (Google, OSRM, Valhalla) dari teks atau file sebagai garis vektor." + }, + "kml": { + "label": "KML / KMZ", + "description": "Muat KML atau KMZ dengan gaya asli, overlay, dan tautan jaringan pada globe." } }, "shared": { @@ -400,6 +404,11 @@ "imported_other": "Mengimpor {{count}} layanan.", "importedWithDropped_other": "Mengimpor {{count}} layanan ({{dropped}} dilewati — pustaka penuh)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "URL KML / KMZ", + "errorSource": "Masukkan URL KML/KMZ atau pilih berkas." + }, "xyz": { "defaultName": "Layer XYZ", "sampleLabel": "Citra USGS", diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index ca3c0d4199..024ae1c723 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -347,6 +347,10 @@ "polyline": { "label": "Aggiungi layer polilinea codificata", "description": "Importa stringhe di polilinea codificata (Google, OSRM, Valhalla) da testo o file come linee vettoriali." + }, + "kml": { + "label": "KML / KMZ", + "description": "Carica KML o KMZ con stili nativi, sovrapposizioni e collegamenti di rete sul globo." } }, "shared": { @@ -404,6 +408,11 @@ "importedWithDropped_one": "Importato {{count}} servizio ({{dropped}} ignorato — libreria piena).", "importedWithDropped_other": "Importati {{count}} servizi ({{dropped}} ignorati — libreria piena)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "URL KML / KMZ", + "errorSource": "Inserisci un URL KML/KMZ o scegli un file." + }, "xyz": { "defaultName": "Layer XYZ", "sampleLabel": "Immagini USGS", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index b1a0dbf0f0..13dd9de1a7 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -345,6 +345,10 @@ "polyline": { "label": "エンコードポリラインレイヤーを追加", "description": "エンコードされたポリライン文字列(Google、OSRM、Valhalla)をテキストまたはファイルからベクターラインとしてインポートします。" + }, + "kml": { + "label": "KML / KMZ", + "description": "元のスタイル、オーバーレイ、ネットワークリンクを保持して KML または KMZ を地球儀に読み込みます。" } }, "shared": { @@ -400,6 +404,11 @@ "imported_other": "{{count}}件のサービスをインポートしました。", "importedWithDropped_other": "{{count}}件のサービスをインポートしました({{dropped}}件はライブラリが上限に達したためスキップされました)。" }, + "kml": { + "defaultName": "KML / KMZ", + "url": "KML / KMZ の URL", + "errorSource": "KML/KMZ の URL を入力するか、ファイルを選択してください。" + }, "xyz": { "defaultName": "XYZレイヤー", "sampleLabel": "USGS 衛星画像", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index af58821709..a021ab3540 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -347,6 +347,10 @@ "polyline": { "label": "დაშიფრული პოლიხაზის შრის დამატება", "description": "დაშიფრული პოლიხაზის სტრიქონების (Google, OSRM, Valhalla) იმპორტი ტექსტიდან ან ფაილებიდან ვექტორული ხაზების სახით." + }, + "kml": { + "label": "KML / KMZ", + "description": "ჩატვირთეთ KML ან KMZ გლობუსზე ორიგინალური სტილებით, გადაფარვებითა და ქსელური ბმულებით." } }, "shared": { @@ -404,6 +408,11 @@ "importedWithDropped_one": "დაიმპორტდა {{count}} სერვისი ({{dropped}} გამოტოვდა — ბიბლიოთეკა სავსეა).", "importedWithDropped_other": "დაიმპორტდა {{count}} სერვისი ({{dropped}} გამოტოვდა — ბიბლიოთეკა სავსეა)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "KML / KMZ URL", + "errorSource": "შეიყვანეთ KML/KMZ URL ან აირჩიეთ ფაილი." + }, "xyz": { "defaultName": "XYZ შრე", "sampleLabel": "USGS-ის სურათები", diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index 94a708d429..b0522c0cf8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -345,6 +345,10 @@ "polyline": { "label": "인코딩된 폴리라인 레이어 추가", "description": "인코딩된 폴리라인 문자열(Google, OSRM, Valhalla)을 텍스트 또는 파일에서 벡터 라인으로 가져옵니다." + }, + "kml": { + "label": "KML / KMZ", + "description": "원본 스타일, 오버레이 및 네트워크 링크를 유지하여 KML 또는 KMZ를 지구본에 불러옵니다." } }, "shared": { @@ -400,6 +404,11 @@ "imported_other": "서비스 {{count}}개를 가져왔습니다.", "importedWithDropped_other": "서비스 {{count}}개를 가져왔습니다({{dropped}}개는 라이브러리가 가득 차서 건너뜀)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "KML / KMZ URL", + "errorSource": "KML/KMZ URL을 입력하거나 파일을 선택하세요." + }, "xyz": { "defaultName": "XYZ 레이어", "sampleLabel": "USGS 영상", diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index aeb6db582e..2f5b4a27ee 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -347,6 +347,10 @@ "polyline": { "label": "Gecodeerde polylijnlaag toevoegen", "description": "Gecodeerde polylijn-tekenreeksen (Google, OSRM, Valhalla) uit tekst of bestanden importeren als vectorlijnen." + }, + "kml": { + "label": "KML / KMZ", + "description": "Laad KML of KMZ met oorspronkelijke stijlen, overlays en netwerkkoppelingen op de globe." } }, "shared": { @@ -404,6 +408,11 @@ "importedWithDropped_one": "{{count}} service geïmporteerd ({{dropped}} overgeslagen — bibliotheek vol).", "importedWithDropped_other": "{{count}} services geïmporteerd ({{dropped}} overgeslagen — bibliotheek vol)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "KML-/KMZ-URL", + "errorSource": "Voer een KML/KMZ-URL in of kies een bestand." + }, "xyz": { "defaultName": "XYZ-laag", "sampleLabel": "USGS-luchtfoto's", diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index f280ec5303..1e2bab1174 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -347,6 +347,10 @@ "polyline": { "label": "Adicionar camada de polilinha codificada", "description": "Importar strings de polilinha codificada (Google, OSRM, Valhalla) de texto ou arquivos como linhas vetoriais." + }, + "kml": { + "label": "KML / KMZ", + "description": "Carregue KML ou KMZ com estilos nativos, sobreposições e links de rede no globo." } }, "shared": { @@ -404,6 +408,11 @@ "importedWithDropped_one": "{{count}} serviço importado ({{dropped}} ignorado — biblioteca cheia).", "importedWithDropped_other": "{{count}} serviços importados ({{dropped}} ignorados — biblioteca cheia)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "URL KML / KMZ", + "errorSource": "Insira uma URL KML/KMZ ou escolha um arquivo." + }, "xyz": { "defaultName": "Camada XYZ", "sampleLabel": "Imagens USGS", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index cd96cecf9d..a92a81866b 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -351,6 +351,10 @@ "polyline": { "label": "Добавить слой закодированной полилинии", "description": "Импорт закодированных строк полилиний (Google, OSRM, Valhalla) из текста или файлов в виде векторных линий." + }, + "kml": { + "label": "KML / KMZ", + "description": "Загрузите KML или KMZ на глобус с исходными стилями, наложениями и сетевыми ссылками." } }, "shared": { @@ -412,6 +416,11 @@ "importedWithDropped_many": "Импортировано {{count}} сервисов ({{dropped}} пропущено — библиотека заполнена).", "importedWithDropped_other": "Импортировано {{count}} сервиса ({{dropped}} пропущено — библиотека заполнена)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "URL KML / KMZ", + "errorSource": "Введите URL KML/KMZ или выберите файл." + }, "xyz": { "defaultName": "Слой XYZ", "sampleLabel": "Снимки USGS", diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index 5eeba7597b..08acc2901b 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -345,6 +345,10 @@ "polyline": { "label": "เพิ่มเลเยอร์โพลีไลน์ที่เข้ารหัส", "description": "นำเข้าสตริงโพลีไลน์ที่เข้ารหัส (Google, OSRM, Valhalla) จากข้อความหรือไฟล์เป็นเส้นเวกเตอร์" + }, + "kml": { + "label": "KML / KMZ", + "description": "โหลด KML หรือ KMZ บนลูกโลกโดยคงรูปแบบต้นฉบับ ภาพซ้อนทับ และลิงก์เครือข่าย" } }, "shared": { @@ -400,6 +404,11 @@ "imported_other": "นำเข้าบริการแล้ว {{count}} รายการ", "importedWithDropped_other": "นำเข้าบริการแล้ว {{count}} รายการ (ข้าม {{dropped}} รายการ — คลังเต็ม)" }, + "kml": { + "defaultName": "KML / KMZ", + "url": "URL ของ KML / KMZ", + "errorSource": "ป้อน URL ของ KML/KMZ หรือเลือกไฟล์" + }, "xyz": { "defaultName": "เลเยอร์ XYZ", "sampleLabel": "ภาพถ่าย USGS", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index edd11302ba..ca94c40778 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -347,6 +347,10 @@ "polyline": { "label": "Kodlanmış Çoklu Çizgi Katmanı Ekle", "description": "Kodlanmış çoklu çizgi dizelerini (Google, OSRM, Valhalla) metin veya dosyalardan vektör çizgiler olarak içe aktarın." + }, + "kml": { + "label": "KML / KMZ", + "description": "KML veya KMZ dosyalarını özgün stiller, kaplamalar ve ağ bağlantılarıyla küreye yükleyin." } }, "shared": { @@ -404,6 +408,11 @@ "importedWithDropped_one": "{{count}} servis içe aktarıldı ({{dropped}} tanesi atlandı — kitaplık dolu).", "importedWithDropped_other": "{{count}} servis içe aktarıldı ({{dropped}} tanesi atlandı — kitaplık dolu)." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "KML / KMZ URL'si", + "errorSource": "Bir KML/KMZ URL’si girin veya dosya seçin." + }, "xyz": { "defaultName": "XYZ Katmanı", "sampleLabel": "USGS uydu görüntüsü", diff --git a/apps/geolibre-desktop/src/i18n/locales/vi.json b/apps/geolibre-desktop/src/i18n/locales/vi.json index f999d785f8..9c2586d7f9 100644 --- a/apps/geolibre-desktop/src/i18n/locales/vi.json +++ b/apps/geolibre-desktop/src/i18n/locales/vi.json @@ -345,6 +345,10 @@ "polyline": { "label": "Thêm lớp polyline mã hóa", "description": "Nhập các chuỗi polyline mã hóa (Google, OSRM, Valhalla) từ văn bản hoặc tệp dưới dạng đường vector." + }, + "kml": { + "label": "KML / KMZ", + "description": "Tải KML hoặc KMZ lên địa cầu với kiểu gốc, lớp phủ và liên kết mạng." } }, "shared": { @@ -400,6 +404,11 @@ "errorRead": "Không thể đọc tệp đó dưới dạng thư viện dịch vụ.", "imported_other": "Đã nhập dịch vụ {{count}}." }, + "kml": { + "defaultName": "KML / KMZ", + "url": "URL KML / KMZ", + "errorSource": "Nhập URL KML/KMZ hoặc chọn một tệp." + }, "xyz": { "defaultName": "Lớp XYZ", "sampleLabel": "Hình ảnh USGS", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index c35b373b03..f0b868f37b 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -345,6 +345,10 @@ "polyline": { "label": "添加编码折线图层", "description": "将编码折线字符串(Google、OSRM、Valhalla)从文本或文件导入为矢量线。" + }, + "kml": { + "label": "KML / KMZ", + "description": "在地球仪上加载 KML 或 KMZ,保留原生样式、叠加层和网络链接。" } }, "shared": { @@ -400,6 +404,11 @@ "imported_other": "已导入 {{count}} 个服务。", "importedWithDropped_other": "已导入 {{count}} 个服务({{dropped}} 个因库已满而跳过)。" }, + "kml": { + "defaultName": "KML / KMZ", + "url": "KML / KMZ URL", + "errorSource": "输入 KML/KMZ URL 或选择文件。" + }, "xyz": { "defaultName": "XYZ 图层", "sampleLabel": "USGS 影像", diff --git a/apps/geolibre-desktop/src/lib/ui-profile.ts b/apps/geolibre-desktop/src/lib/ui-profile.ts index 55e75efbe8..aed1a1fb49 100644 --- a/apps/geolibre-desktop/src/lib/ui-profile.ts +++ b/apps/geolibre-desktop/src/lib/ui-profile.ts @@ -160,6 +160,7 @@ export const DATA_SOURCE_CATALOG: readonly DataSourceCatalogEntry[] = [ labelKey: "toolbar.layerType.czml", tier: "advanced", }, + { id: "kml", section: "threeD", labelKey: "addData.kind.kml.label", tier: "advanced" }, { id: "gltf-model", section: "threeD", diff --git a/apps/geolibre-desktop/tsconfig.json b/apps/geolibre-desktop/tsconfig.json index a4d45813ff..5ac0d3b88b 100644 --- a/apps/geolibre-desktop/tsconfig.json +++ b/apps/geolibre-desktop/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, + "types": ["node", "react", "react-dom", "vite/client"], "paths": { "@/*": ["./src/*"] } diff --git a/apps/geolibre-desktop/vite.config.ts b/apps/geolibre-desktop/vite.config.ts index 4ba09c0bfe..3b73397384 100644 --- a/apps/geolibre-desktop/vite.config.ts +++ b/apps/geolibre-desktop/vite.config.ts @@ -1209,8 +1209,9 @@ export default defineConfig({ // cog-tiler-wasm's mask-aware LERC decoder (lerc-decoder.js) reaches // these through dynamic import() the first time a LERC COG opens; they // are geotiff's own codec packages, listed here for the same - // discover-and-reload reason as above. (`lerc` itself is excluded below: - // it locates its .wasm via import.meta.url.) + // discover-and-reload reason as above. The raster loader supplies LERC's + // WASM URL explicitly, so its ESM decoder can also be pre-bundled. + "lerc", "pako", "zstddec", // Cesium (the 3D-globe view). Pre-bundle it up front so esbuild applies @@ -1266,11 +1267,6 @@ export default defineConfig({ // breaks that asset reference so the tiler stops rendering. Serve it // as-is. (Its plain-JS deps are pre-bundled via optimizeDeps.include.) "cog-tiler-wasm", - // lerc 4.x (cog-tiler-wasm's mask-aware LERC decoder) fetches - // lerc-wasm.wasm via `new URL(..., import.meta.url)`; pre-bundled, that - // resolves against the .vite/deps chunk and the request falls through to - // index.html ("expected magic word 00 61 73 6d, found 3c 21 64 6f"). - "lerc", // h5wasm (local NetCDF/HDF5 reader) loads its libhdf5 .wasm via // `new URL(..., import.meta.url)`; esbuild pre-bundling mangles that // asset reference, so serve it as-is. Only reached through the lazy diff --git a/docs/architecture.md b/docs/architecture.md index af050ad37f..8fa82495fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ The 2D MapLibre map can be joined by — or replaced with — a 3D globe rendere - **Lazy loading.** The whole Cesium engine (~4.8 MB) is `import()`-ed only when a globe first mounts, kept in its own build chunk (`manualChunks`) and off the 2D boot path. A Vite plugin (`vite-plugins/copy-cesium-assets.ts`) stages Cesium's runtime Workers/Assets/Widgets into `public/cesium/` and the canvas sets `window.CESIUM_BASE_URL` so the engine finds them. - **Drawing.** Both engines implement `drawExtent` with a shared pointer lifecycle and geographic extents. Cesium uses a draggable entity for manual placement and ground-clamped rectangles for extraction previews. Escape, pointer cancellation, focus loss, and renderer teardown restore navigation. The raster-subset panel uses these operations on both renderers; crossing extents retain west > east so the extractor can explain that they must be split. The basemap extraction panel shares the drawing helper, while its menu remains gated on vector-style support. - **Camera sync.** `packages/map/src/cesium-camera.ts` converts between MapLibre's Web-Mercator `MapViewState` (zoom, nadir-referenced pitch, bearing) and Cesium's camera (metric range, horizon-referenced pitch, heading), matched by **ground resolution** (metres per pixel) so the on-screen scale stays in step even when the panes differ in height. `CesiumCanvas` seeds its camera from the shared `mapView`, applies store changes to the globe, and writes the globe's own moves back — bidirectional, like the 2D panes — with a tolerance check that suppresses the apply→`moveEnd` echo so there is no jitter loop. -- **Layer sync.** `CesiumLayerSync` (`packages/map/src/cesium-layer-sync.ts`) reconciles the store's `GeoLibreLayer[]` onto the globe the way `MapController.syncLayers` does for MapLibre, reusing the same per-pane visibility overrides and group effects as `SecondaryMapCanvas`. It renders the kinds where Cesium is the natural fit — GeoJSON (a draped `GeoJsonDataSource` whose entities are styled per feature by `createFeatureStyleResolver` (`packages/map/src/cesium-feature-style.ts`, issue #2278): it evaluates the same MapLibre expressions `@geolibre/core` builds for the 2D map — single, categorized, graduated, rule-based with per-rule symbol overrides, and expression modes, simplestyle properties, proportional sizing, metre-unit strokes — with the style-spec engine, so polygon fills and outlines, line widths and arrow decorations, circle radii, classified marker sprites, fill patterns, and the layer's zoom range (a shared `DistanceDisplayCondition`) match the 2D map; points draw as circles unless the layer renders markers; a point-only layer with `pointRenderer: "cluster"` clusters through the data source's `EntityCluster` with bubbles and abbreviated counts styled like the 2D cluster layers and switched off past `clusterMaxZoom`, and a point-only layer above 50 000 features bypasses entities for one `PointPrimitiveCollection` whose primitives carry the feature reference on `id` so picking, highlighting, and filters still work (`packages/map/src/cesium-points.ts`, issue #2282; the heatmap renderer has no globe form and draws as plain circles); with field or expression labels, halos, and label zoom limits), XYZ/raster/WMTS, WMS, ArcGIS MapServer (`ArcGisMapServerImageryProvider`), and georeferenced image overlays (`SingleTileImageryProvider`) (as `ImageryLayer`s), and 3D Tiles (a `Cesium3DTileset` primitive that consumes the layer's tileset URL, request headers, and altitude offset directly, and whose features are classified by `cesium-tileset-style.ts` (issue #2290): the layer's colour expression and composed feature filter are translated from MapLibre expressions into the 3D Tiles styling language and applied as a `Cesium3DTileStyle`, so a tileset categorizes, graduates, or follows a rule tree the way an extruded vector layer does, and the layer opacity reaches it as a white multiply that fades textured tiles without tinting them — an untranslatable colour or filter leaves the tileset drawing its own colours and showing every feature rather than guessing; the attribute names the Style panel lists come from the first rendered tile, published onto `metadata.fields` because a tileset carries its schema in the tiles rather than in the layer record; an ArcGIS I3S scene layer goes through Cesium's own `I3SDataProvider` instead of loaders.gl, a Gaussian-splat layer whose asset is a 3D Tiles tileset renders through the same branch because Cesium draws `KHR_gaussian_splatting` tiles natively, and a point cloud in 3D Tiles form gets `pointCloudShading` eye-dome lighting), plus COPC point clouds decoded in the browser with the `copc` package into a bounded `PointPrimitiveCollection` preview reprojected through the archive's WKT (`packages/map/src/cesium-point-cloud.ts`, issue #2285) — with live visibility/opacity, rebuild-on-source-change, and removal. Tile sources that reach the 2D map through a MapLibre custom protocol — COG tiles from the WASM tiler, raster PMTiles, local MBTiles, the desktop's native XYZ/WMS fetcher, KML super-overlays — render through `ProtocolImageryProvider` (`packages/map/src/cesium-protocol-imagery.ts`, issue #2283), a Cesium `ImageryProvider` that expands the `{z}/{x}/{y}` template the way MapLibre does, hands each tile URL to the handler in `maplibregl.config.REGISTERED_PROTOCOLS` (process-wide, so it works with no MapLibre map mounted), and decodes the bytes into an `ImageBitmap` flipped the way Cesium expects; an unregistered scheme is reported as a layer error rather than drawn blank. COG layers open the same `cog-tiler-wasm` source the raster control uses and render from the persisted `metadata.rasterState` (`cesium-cog-imagery.ts`), so a COG looks the same on both renderers; a COG restored from a project with only a desktop file path stays 2D-only until the raster control has reopened it. The raster symbology (brightness, contrast, saturation, hue) maps onto `ImageryLayer`'s own adjustments for every imagery kind. Tile-backed vector layers (`vector-tiles`, vector PMTiles, vector MBTiles) are draped (`cesium-drape.ts`, issue #2284): the globe runs one hidden, single-tile MapLibre map that receives the same store layers through the headless `createLayerSync` path, renders one Web Mercator tile at a time (`jumpTo` the tile centre, wait for `idle`, read the canvas back), and feeds Cesium through a `ProtocolImageryProvider`, so the Style Spec is never re-implemented and every style, filter, order, and visibility edit rebuilds the drape's imagery layer. Draped content is flat, its labels are placed per tile, and their glyphs come from a remote font host (so a local archive viewed offline drapes without text); picking on it, `arcgis` VectorTileServer layers (painted by their own control), Zarr, EPT and plain LAS/LAZ point clouds, raw `.ply`/`.splat` splat files, and deck.gl viz are still skipped on the globe and render in the 2D panes; the exported `isCesiumSupportedLayerType` predicate lets the pane's layer menu — and the Layers panel, when the globe is the primary renderer — tag those "2D only". +- **Layer sync.** `CesiumLayerSync` (`packages/map/src/cesium-layer-sync.ts`) reconciles the store's `GeoLibreLayer[]` onto the globe the way `MapController.syncLayers` does for MapLibre, reusing the same per-pane visibility overrides and group effects as `SecondaryMapCanvas`. It renders the kinds where Cesium is the natural fit — GeoJSON (a draped `GeoJsonDataSource` whose entities are styled per feature by `createFeatureStyleResolver` (`packages/map/src/cesium-feature-style.ts`, issue #2278): it evaluates the same MapLibre expressions `@geolibre/core` builds for the 2D map — single, categorized, graduated, rule-based with per-rule symbol overrides, and expression modes, simplestyle properties, proportional sizing, metre-unit strokes — with the style-spec engine, so polygon fills and outlines, line widths and arrow decorations, circle radii, classified marker sprites, fill patterns, and the layer's zoom range (a shared `DistanceDisplayCondition`) match the 2D map; points draw as circles unless the layer renders markers; a point-only layer with `pointRenderer: "cluster"` clusters through the data source's `EntityCluster` with bubbles and abbreviated counts styled like the 2D cluster layers and switched off past `clusterMaxZoom`, and a point-only layer above 50 000 features bypasses entities for one `PointPrimitiveCollection` whose primitives carry the feature reference on `id` so picking, highlighting, and filters still work (`packages/map/src/cesium-points.ts`, issue #2282; the heatmap renderer has no globe form and draws as plain circles); with field or expression labels, halos, and label zoom limits), XYZ/raster/WMTS, WMS, ArcGIS MapServer (`ArcGisMapServerImageryProvider`), and georeferenced image overlays (`SingleTileImageryProvider`) (as `ImageryLayer`s), and 3D Tiles (a `Cesium3DTileset` primitive that consumes the layer's tileset URL, request headers, and altitude offset directly, and whose features are classified by `cesium-tileset-style.ts` (issue #2290): the layer's colour expression and composed feature filter are translated from MapLibre expressions into the 3D Tiles styling language and applied as a `Cesium3DTileStyle`, so a tileset categorizes, graduates, or follows a rule tree the way an extruded vector layer does, and the layer opacity reaches it as a white multiply that fades textured tiles without tinting them — an untranslatable colour or filter leaves the tileset drawing its own colours and showing every feature rather than guessing; the attribute names the Style panel lists come from the first rendered tile, published onto `metadata.fields` because a tileset carries its schema in the tiles rather than in the layer record; an ArcGIS I3S scene layer goes through Cesium's own `I3SDataProvider` instead of loaders.gl, a Gaussian-splat layer whose asset is a 3D Tiles tileset renders through the same branch because Cesium draws `KHR_gaussian_splatting` tiles natively, and a point cloud in 3D Tiles form gets `pointCloudShading` eye-dome lighting), plus COPC point clouds decoded in the browser with the `copc` package into a bounded `PointPrimitiveCollection` preview reprojected through the archive's WKT (`packages/map/src/cesium-point-cloud.ts`, issue #2285) — with live visibility/opacity, rebuild-on-source-change, and removal. Tile sources that reach the 2D map through a MapLibre custom protocol — COG tiles from the WASM tiler, raster PMTiles, local MBTiles, the desktop's native XYZ/WMS fetcher, KML super-overlays — render through `ProtocolImageryProvider` (`packages/map/src/cesium-protocol-imagery.ts`, issue #2283), a Cesium `ImageryProvider` that expands the `{z}/{x}/{y}` template the way MapLibre does, hands each tile URL to the handler in `maplibregl.config.REGISTERED_PROTOCOLS` (process-wide, so it works with no MapLibre map mounted), and decodes the bytes into an `ImageBitmap` flipped the way Cesium expects; an unregistered scheme is reported as a layer error rather than drawn blank. COG layers open the same `cog-tiler-wasm` source the raster control uses and render from the persisted `metadata.rasterState` (`cesium-cog-imagery.ts`), so a COG looks the same on both renderers; a COG restored from a project with only a desktop file path stays 2D-only until the raster control has reopened it. The raster symbology (brightness, contrast, saturation, hue) maps onto `ImageryLayer`'s own adjustments for every imagery kind. Tile-backed vector layers (`vector-tiles`, vector PMTiles, vector MBTiles) are draped (`cesium-drape.ts`, issue #2284): the globe runs one hidden, single-tile MapLibre map that receives the same store layers through the headless `createLayerSync` path, renders one Web Mercator tile at a time (`jumpTo` the tile centre, wait for `idle`, read the canvas back), and feeds Cesium through a `ProtocolImageryProvider`, so the Style Spec is never re-implemented and every style, filter, order, and visibility edit rebuilds the drape's imagery layer. Draped content is flat, its labels are placed per tile, and their glyphs come from a remote font host (so a local archive viewed offline drapes without text); picking on it, `arcgis` VectorTileServer layers (painted by their own control), Zarr, EPT and plain LAS/LAZ point clouds, raw `.ply`/`.splat` splat files, and deck.gl viz are still skipped on the globe and render in the 2D panes; the exported `isCesiumSupportedLayerType` predicate lets the pane's layer menu — and the Layers panel, when the globe is the primary renderer — tag those "2D only". Native KML/KMZ documents use `KmlDataSource`, preserving document styling, network links, and overlays; visibility and opacity are applied without replacing the original color properties. Local KMZ archives persist as data URLs. The Elevation Profile plugin draws native entities and samples the active terrain provider, rejecting results when that provider changes. - **Globe interaction parity.** The primary globe publishes ground cursor coordinates and optional terrain elevation to the shared status bar, clearing them on pointer exit and teardown. Projected scene modes report a Mercator projection. Home, scene-mode, and fullscreen widgets participate in shared control positioning without remounting their DOM. In the Controls menu they answer to the existing entries: **Compass** governs the Home (reset view) button and **Globe** governs the scene-mode picker, as the globe's counterparts of the 2D reset-bearing and projection controls, and the toolbar replays those choices onto a freshly mounted globe. - **Environment plugins.** The Sun simulation, Atmospheric Effects, Flight Simulator, Clouds, and Precipitation plugins declare both engines and branch on `app.getCesiumScene()` (issue #2287), a typed handle to the primary globe's namespace, widget, scene, camera, and clock that `CesiumEngine.getCesiumScene()` exposes as the globe's counterpart to `getMap()`. On the globe the Sun drives a native `SunLight`, `globe.enableLighting`, and the scene clock (the night-side depth maps onto `globe.vertexShadowDarkness`); Effects toggles the sky box and atmosphere and re-tints `SkyAtmosphere` by hue/saturation/brightness shifts from the halo settings; the Flight Simulator shares its keyboard, physics, and HUD with the 2D map behind a camera adapter that places `camera.setView` each frame with `screenSpaceCameraController.enableInputs` suspended; the weather overlays are store tile layers and render through `CesiumLayerSync` unchanged. The Spinning Globe control needs no branch: it drives the store through the control host's MapLibre facade. `DesktopShell` re-attaches these plugins after either engine mounts, so a renderer swap rebinds them the way a MapLibre re-init does. - **Persistence.** A pane's `viewKind` is part of `SecondaryMapView` and round-trips through the `.geolibre.json` project format (`normalizeSecondaryMapViews`), so a project saved with a globe pane reopens as the globe, token or not. The workspace's own choice persists as the top-level `primaryRenderer` (`normalizePrimaryRenderer`), written only when it is not the default — so a MapLibre project is byte-identical to one saved before the setting existed, and a 3D-first project reopens directly on the globe. diff --git a/docs/cesium-parity-testing.md b/docs/cesium-parity-testing.md index c2af6f4cbe..5c19a925c5 100644 --- a/docs/cesium-parity-testing.md +++ b/docs/cesium-parity-testing.md @@ -26,6 +26,28 @@ here; #2291 describes it as a possible follow-up. ## Remaining work and ordering +September 11 audit: the open Cesium trackers are #2259, #2261, and #2262. +The tileset styling requirement of #2290 landed in #2337 and native CZML in +#2350. Although #2350 closed #2290, several requirements remain. +Clipping polygons, terrain sampling in the measurement tools, +Ion terrain assets, and Google Photorealistic 3D Tiles remain outstanding. +The layer-format gaps listed below also remain, including ArcGIS vector tiles, +drape picking, Zarr, raw point clouds and splats, and deck.gl visualizations. + +The control host now forwards camera and geographic pointer events and reports +the actual canvas container dimensions. It rejects source mutations as well as +style-layer mutations. This fixes the facade contract but does not make controls +that paint through MapLibre compatible; their engine declarations remain gated. + +The Vite audit also found that excluding `lerc` from dependency optimization +externalized Cesium's LERC 2 import to the top-level LERC 4 package, preventing +the globe from opening. Both versions now remain in their respective dependency +graphs. The globe's COG loader supplies LERC 4's WASM URL explicitly, as the +2D raster loader already does. A real single-band Athens LERC DEM in +EPSG:2100 renders with a terrain colormap in both themes. The Layers panel +no longer applies its MapLibre-source placeholder warning to native Cesium +layers. + | Issues | Next work | Required evidence | | --- | --- | --- | | #2276 | Implemented manual placement and shared extent drawing | Real pin drag and Done, rectangle drawing and Escape in both themes, renderer swaps, and Esri World Imagery extraction to a 22×12 EPSG:4326 GeoTIFF with nonconstant pixels; unit coverage includes antimeridian extents, pointer ownership, sky release, blur, and cancellation | @@ -41,7 +63,7 @@ here; #2291 describes it as a possible follow-up. | #2287 (implemented) | Native environment plugins | Sun clock and lighting on/off, atmosphere/sky box on/off and restore, spin start/stop, cloud imagery add/remove, and flight take-over/teardown verified in the real app in both themes; unit tests cover each Cesium branch against the real Cesium maths with a faked widget | | #2288, #2262 | Enforce declared support in activation, URL dispatch, project restore, delayed controls, and command palette | Tests cover unsupported callbacks, renderer round trips, saved settings, and compatible-control remounting. The wider control facade and native plugin implementations remain separate work. | | #2289 | Python/MCP/embed renderer authoring | Project round trips, renderer events, pane kinds, invalid inputs, and docs examples | -| #2290 (Ion assets implemented) | Cesium-native authoring features | Ion assets: Cesium OSM Buildings (asset 96188) and Bing Aerial (asset 2) added from the Add Data dialog on the globe in both themes, the same project reopened on the 2D map showing the "3D only" badge, a missing token surfacing as a layer error; unit tests cover the layer builder, the asset-id parser, the globe's tileset/imagery routing through `IonResource`/`IonImageryProvider`, rebuild on asset change, and the Python/MCP builders. Tileset styling, clipping polygons, KML/CZML, and terrain sampling remain separate follow-ups. | +| #2290 (Ion assets implemented) | Cesium-native authoring features | Ion assets: Cesium OSM Buildings (asset 96188) and Bing Aerial (asset 2) added from the Add Data dialog on the globe in both themes, the same project reopened on the 2D map showing the "3D only" badge, a missing token surfacing as a layer error; unit tests cover the layer builder, the asset-id parser, the globe's tileset/imagery routing through `IonResource`/`IonImageryProvider`, rebuild on asset change, and the Python/MCP builders. Tileset styling and CZML have merged. Native KML/KMZ and elevation profiles are implemented: a real San Francisco landmarks KMZ retains its billboard styles and labels in both themes; a drawn 3.43 km profile samples World Terrain from -27 m to 74 m. Tests cover document loading, cancellation, opacity, cleanup, Python serialization, and terrain-provider replacement. Clipping polygons, Terrain Measure, Ion terrain assets, and Google Photorealistic 3D Tiles remain follow-ups. | | #2261, #2259 | Update umbrella completion only after child requirements are verified | Accurate supported-layer predicates and an explicit record of remaining gaps | ## Test gates diff --git a/docs/mcp.md b/docs/mcp.md index 30c5bc37e6..7c4a6a94fb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -105,6 +105,7 @@ Give it a directory meant for maps, not your home directory. | `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. | +| `add_cesium_kml_layer` | Native globe KML/KMZ from a URL, inline XML, or KMZ data URL, preserving document styles and overlays. | ### Editing diff --git a/docs/plugin-api.md b/docs/plugin-api.md index a9deb67247..9efffd961d 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -1164,7 +1164,11 @@ over its canvas — so the scoped CSS in `index.css` keeps applying — and hand `Map`. The facade answers: - `getContainer`, `getCanvas`, `isStyleLoaded`, and the `Evented` methods - (`on` / `off` / `once` / `fire`). + (`on` / `off` / `once` / `fire`). `getContainer` returns the sized canvas + parent so controls can anchor their panels. Camera `movestart`, `move`, and + `moveend`, canvas `resize`, and geographic mouse events reach subscriptions; + pointer events over space are omitted because they have no ground location. + The host removes these subscriptions when the globe is destroyed. - `getCenter`, `getZoom`, `getBearing`, `getPitch` from the store's map view, and `jumpTo` / `flyTo` / `easeTo` by writing it back. - `project`, `unproject`, and `getBounds` from the live scene: a coordinate is @@ -1176,9 +1180,9 @@ over its canvas — so the scoped CSS in `index.css` keeps applying — and hand shapes MapLibre's globe projection answers with, so a control keeps running instead of throwing mid-render. - `setStyle(url)`, routed to the project basemap. -- `addSource` / `getSource` / `removeSource`, kept in a map on the facade. -Everything that paints through the Mapbox Style Spec — `addLayer`, +Everything that paints through the Mapbox Style Spec, including `addSource`, +`removeSource`, `addLayer`, `removeLayer`, `setPaintProperty`, `setLayoutProperty`, `getStyle` — **throws**. That is the honest boundary: a control that draws its own map layers has no globe representation, and a silent no-op would leave it reporting success while diff --git a/docs/python.md b/docs/python.md index 990d36aec4..ad0a11c5b1 100644 --- a/docs/python.md +++ b/docs/python.md @@ -297,6 +297,7 @@ m.on_layer_change(lambda e: print("layers", e["layerIds"])) | `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_cesium_kml(url=None, name=, data=, source_path=, **style)` | Native KML/KMZ on the globe with document styles and overlays. Supply a URL, inline XML, or a KMZ data URL; use `add_kml` for vector conversion. | | `add_video(urls, coordinates, name=, **style)` | Add a georeferenced video (four `[lng, lat]` corners). | | `add_basemap(basemap)` | Set the background basemap. | | `split_map(left_layers=None, right_layers=None, orientation=, position=, control_position=)` | Add a swipe (split-map) comparison slider between two layer sets. | diff --git a/packages/core/src/cesium-ion.ts b/packages/core/src/cesium-ion.ts index ac5605b360..e7eb24b549 100644 --- a/packages/core/src/cesium-ion.ts +++ b/packages/core/src/cesium-ion.ts @@ -1,4 +1,5 @@ import { isCzmlLayer } from "./czml"; +import { isCesiumKmlLayer } from "./cesium-kml"; import { DEFAULT_LAYER_STYLE, type GeoLibreLayer } from "./types"; // Cesium Ion assets (issue #2290): 3D Tiles and imagery referenced by Ion @@ -58,7 +59,7 @@ export function cesiumIonAssetKind(layer: Pick): CesiumIo * `isCesiumSupportedLayerType`, for the Layers panel to badge on the 2D map. */ export function isCesiumOnlyLayer(layer: Pick): boolean { - return isCesiumIonLayer(layer) || isCzmlLayer(layer); + return isCesiumIonLayer(layer) || isCzmlLayer(layer) || isCesiumKmlLayer(layer); } function newLayerId(): string { diff --git a/packages/core/src/cesium-kml.ts b/packages/core/src/cesium-kml.ts new file mode 100644 index 0000000000..97d48c03cc --- /dev/null +++ b/packages/core/src/cesium-kml.ts @@ -0,0 +1,48 @@ +import { DEFAULT_LAYER_STYLE, type GeoLibreLayer } from "./types"; + +/** Native KML/KMZ documents retain their own styling on the globe. */ +export const CESIUM_KML_SOURCE_KIND = "cesium-kml"; + +export function isCesiumKmlLayer(layer: Pick): boolean { + return layer.metadata?.sourceKind === CESIUM_KML_SOURCE_KIND; +} + +export interface CesiumKmlLayerOptions { + id?: string; + name: string; + /** HTTP(S) KML or KMZ URL. Relative resources resolve against this URL. */ + url?: string; + /** Inline XML, or a KMZ data URL so local archives survive project saves. */ + data?: string; + sourcePath?: string; +} + +export function cesiumKmlSource(layer: Pick): string | null { + if (!isCesiumKmlLayer(layer)) return null; + for (const value of [layer.source.kmlData, layer.source.url]) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +} + +export function createCesiumKmlLayer(options: CesiumKmlLayerOptions): GeoLibreLayer { + const data = options.data?.trim(); + const url = options.url?.trim(); + if (!data && !url) throw new Error("Provide a KML/KMZ document or URL."); + const id = options.id ?? crypto.randomUUID(); + return { + id, + name: options.name, + type: "3d-tiles", + ...(options.sourcePath ? { sourcePath: options.sourcePath } : {}), + source: { type: "3d-tiles", sourceId: id, ...(data ? { kmlData: data } : { url }) }, + visible: true, + opacity: 1, + style: { ...DEFAULT_LAYER_STYLE }, + metadata: { + sourceKind: CESIUM_KML_SOURCE_KIND, + externalNativeLayer: true, + identifiable: false, + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a725daaf5..b3a9958f44 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -191,3 +191,10 @@ export { readStoredAuthorName, setStoredAuthorName, } from "./editor-identity"; +export { + CESIUM_KML_SOURCE_KIND, + isCesiumKmlLayer, + cesiumKmlSource, + createCesiumKmlLayer, + type CesiumKmlLayerOptions, +} from "./cesium-kml"; diff --git a/packages/map/src/cesium-control-host.ts b/packages/map/src/cesium-control-host.ts index bec73e301b..c977d61964 100644 --- a/packages/map/src/cesium-control-host.ts +++ b/packages/map/src/cesium-control-host.ts @@ -17,8 +17,8 @@ type CesiumNs = typeof import("@cesium/engine"); const OFF_SCREEN_PX = -1e6; class CesiumMapFacade extends maplibregl.Evented { - private sources = new Map(); - private layers = new Map(); + private cleanups: Array<() => void> = []; + private disposed = false; constructor( private host: CesiumControlHost, @@ -26,10 +26,73 @@ class CesiumMapFacade extends maplibregl.Evented { private Cesium: CesiumNs | null, ) { super(); + for (const [event, name] of [ + [viewer.camera.moveStart, "movestart"], + [viewer.camera.changed, "move"], + [viewer.camera.moveEnd, "moveend"], + ] as const) { + if (event) this.cleanups.push(event.addEventListener(() => this.fire(name))); + } + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(() => this.fire("resize")); + observer.observe(viewer.canvas); + this.cleanups.push(() => observer.disconnect()); + } + for (const name of [ + "click", + "dblclick", + "mousemove", + "mousedown", + "mouseup", + "contextmenu", + ] as const) { + const listener = (originalEvent: MouseEvent) => { + const C = this.Cesium; + const scene = this.scene(); + if (!C || !scene) return; + // `pickGlobeHit` costs a terrain ray intersection, and `mousemove` fires + // on every pointer frame. The engine's own cursor readout already picks + // on move, so skip the work entirely when no control is listening here. + if (!this.listens(name)) return; + const rect = viewer.canvas.getBoundingClientRect(); + const point = new maplibregl.Point( + originalEvent.clientX - rect.left, + originalEvent.clientY - rect.top, + ); + const hit = pickGlobeHit(C, viewer, point); + // A click on space has no geographic location. Do not fabricate the + // view centre for a control that may place a marker or start a query. + if (!hit) return; + // `pickGlobeHit` falls back to a WGS84 ellipsoid pick when the scene has + // no globe, so the conversion cannot assume one is configured either. + const position = (scene.globe?.ellipsoid ?? C.Ellipsoid.WGS84).cartesianToCartographic( + hit.position, + ); + const lngLat = new maplibregl.LngLat( + C.Math.toDegrees(position.longitude), + C.Math.toDegrees(position.latitude), + ); + this.fire( + new maplibregl.Event(name, { + point, + lngLat, + originalEvent, + preventDefault: () => originalEvent.preventDefault(), + }), + ); + }; + viewer.canvas.addEventListener(name, listener); + this.cleanups.push(() => viewer.canvas.removeEventListener(name, listener)); + } } getContainer() { - return this.host.getContainer(); + return this.viewer.canvas.parentElement ?? this.host.getContainer(); + } + + dispose() { + this.disposed = true; + for (const cleanup of this.cleanups.splice(0)) cleanup(); } getCanvas() { @@ -51,7 +114,7 @@ class CesiumMapFacade extends maplibregl.Evented { // controls wait for it before finishing a basemap swap (they would hang // otherwise); it does not promise the pixels have changed. setTimeout(() => { - this.fire(new maplibregl.Event("style.load")); + if (!this.disposed) this.fire(new maplibregl.Event("style.load")); }, 0); } else { throw new Error("CesiumControlHost: setStyle with an object is not supported."); @@ -85,17 +148,15 @@ class CesiumMapFacade extends maplibregl.Evented { } addSource(id: string, source: any) { - this.sources.set(id, source); - return this; + throw new Error("CesiumControlHost: addSource is not supported on the globe."); } getSource(id: string) { - return this.sources.get(id); + return undefined; } removeSource(id: string) { - this.sources.delete(id); - return this; + throw new Error("CesiumControlHost: removeSource is not supported on the globe."); } addLayer(layer: any, beforeId?: string) { @@ -103,8 +164,7 @@ class CesiumMapFacade extends maplibregl.Evented { } removeLayer(id: string) { - this.layers.delete(id); - return this; + throw new Error("CesiumControlHost: removeLayer is not supported on the globe."); } /** @@ -257,6 +317,7 @@ export class CesiumControlHost { } destroy() { + this.facade.dispose(); for (const control of Array.from(this.controls.keys())) { this.removeControl(control); } diff --git a/packages/map/src/cesium-document-opacity.ts b/packages/map/src/cesium-document-opacity.ts new file mode 100644 index 0000000000..bb9c85587e --- /dev/null +++ b/packages/map/src/cesium-document-opacity.ts @@ -0,0 +1,56 @@ +import type { Color, DataSource, Entity, Property } from "@cesium/engine"; + +type CesiumNs = typeof import("@cesium/engine"); + +/** Fade native document graphics without replacing their time-dependent colors. */ +export function bindDocumentOpacity( + C: CesiumNs, + source: DataSource, + opacity: () => number, +): () => void { + const wrapped = new WeakSet(); + const wrap = (graphics: object | undefined, key: string, fallback: Color) => { + if (!graphics) return; + const values = graphics as Record; + const original = values[key]; + if (original && wrapped.has(original)) return; + const property = new C.CallbackProperty((time, result) => { + const color = original?.getValue(time) ?? fallback; + const output = C.Color.clone(color, result); + output.alpha *= Math.max(0, Math.min(1, opacity())); + return output; + }, false); + wrapped.add(property); + values[key] = property; + }; + const apply = (entity: Entity) => { + for (const graphic of [entity.billboard, entity.point, entity.model]) { + wrap(graphic, "color", C.Color.WHITE); + } + wrap(entity.label, "fillColor", C.Color.WHITE); + wrap(entity.label, "backgroundColor", new C.Color(0.165, 0.165, 0.165, 0.8)); + for (const graphic of [ + entity.point, + entity.label, + entity.polygon, + entity.polyline, + entity.wall, + entity.corridor, + entity.rectangle, + entity.ellipse, + ]) { + if (!graphic) continue; + if ("outlineColor" in graphic) wrap(graphic, "outlineColor", C.Color.BLACK); + if ("material" in graphic && graphic.material && "color" in graphic.material) { + wrap(graphic.material, "color", C.Color.WHITE); + } + } + }; + for (const entity of source.entities.values) apply(entity); + // NetworkLink refreshes and streamed CZML packets can add or replace graphics. + return source.entities.collectionChanged.addEventListener( + (_collection, added, _removed, changed) => { + for (const entity of [...added, ...changed]) apply(entity); + }, + ); +} diff --git a/packages/map/src/cesium-layer-sync.ts b/packages/map/src/cesium-layer-sync.ts index fec2663d0b..7f1ddf9f26 100644 --- a/packages/map/src/cesium-layer-sync.ts +++ b/packages/map/src/cesium-layer-sync.ts @@ -1,3 +1,5 @@ +import { cesiumKmlSource, isCesiumKmlLayer } from "@geolibre/core"; +import { bindDocumentOpacity } from "./cesium-document-opacity"; import { cesiumIonAssetId, compileFeatureExpression, @@ -178,7 +180,7 @@ const BOUNDING_SPHERE_STATE_PENDING = 1; */ const ARCGIS_MAP_SERVICE_KIND = "arcgis-map-service"; -type EntryKind = "imagery" | "geojson" | "3dtiles" | "points" | "pointcloud" | "czml"; +type EntryKind = "imagery" | "geojson" | "3dtiles" | "points" | "pointcloud" | "czml" | "kml"; /** The `clock` packet a loaded CZML data source carries, as Cesium exposes it. */ interface CzmlDocumentClock { @@ -220,7 +222,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 (isCzmlLayer(layer) || isCesiumKmlLayer(layer)) return false; if (layer.type === "3d-tiles") return true; if (layer.type === "gaussian-splat") return isSplatTilesetUrl(str(layer.source.url) ?? str(layer.sourcePath)); @@ -249,6 +251,8 @@ interface LayerEntry { abort?: AbortController; /** Removes the one-shot tile listener that reads a tileset's attribute names. */ fieldsListener?: () => void; + documentCleanup?: () => void; + overlayContainer?: HTMLElement; /** Set when the entry is removed mid-load so the resolved handle is discarded. */ cancelled: boolean; /** @@ -504,6 +508,7 @@ function wmtsCapabilities( export function isCesiumSupportedLayerType(layer: GeoLibreLayer): boolean { return ( isCzmlLayer(layer) || + isCesiumKmlLayer(layer) || hasGeoJsonCollection(layer) || layer.type === "geojson" || isTilesetLayer(layer) || @@ -518,6 +523,7 @@ 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 (isCesiumKmlLayer(layer)) return Boolean(cesiumKmlSource(layer)); if (isCzmlLayer(layer)) { const src = czmlSource(layer); return Boolean(src && (src.url || src.data)); @@ -690,6 +696,7 @@ export function imageryColorAdjustments(style: LayerStyle | undefined): { * @returns The EntryKind categorization for the globe renderer. */ function entryKind(layer: GeoLibreLayer): EntryKind { + if (isCesiumKmlLayer(layer)) return "kml"; if (isCzmlLayer(layer)) return "czml"; if (hasRenderableGeoJson(layer)) return planPointRendering(layer).batched ? "points" : "geojson"; if (isTilesetLayer(layer)) return "3dtiles"; @@ -836,6 +843,8 @@ function needsRebuild(prev: GeoLibreLayer, next: GeoLibreLayer): boolean { JSON.stringify(next.source.requestHeaders ?? null) || prev.source.altitudeOffset !== next.source.altitudeOffset ); + case "kml": + return cesiumKmlSource(prev) !== cesiumKmlSource(next); case "czml": return ( czmlSource(prev)?.url !== czmlSource(next)?.url || @@ -1094,7 +1103,7 @@ export class CesiumLayerSync { } } } - } else if (entry.kind === "czml") { + } else if (entry.kind === "czml" || entry.kind === "kml") { const ds = entry.handle as DataSource; if (ds.isLoading || !entry.added) { pending.push(layer.name); @@ -1138,15 +1147,20 @@ export class CesiumLayerSync { */ private cogTiler: Promise> | null = null; private loadCogTiler(): Promise> { - this.cogTiler ??= (this.deps.loadCogTiler ?? (() => import("cog-tiler-wasm")))().then( - cachingCogTiler, - (error) => { - // A failed module load must not poison every later COG for the life - // of the globe; the next COG layer retries the import. - this.cogTiler = null; - throw error; - }, - ); + this.cogTiler ??= ( + this.deps.loadCogTiler ?? + (async () => { + const module = await import("cog-tiler-wasm"); + const { default: wasmUrl } = await import("lerc/lerc-wasm.wasm?url"); + module.configureLercDecoder({ wasmUrl }); + return module; + }) + )().then(cachingCogTiler, (error) => { + // A failed module load must not poison every later COG for the life + // of the globe; the next COG layer retries the import. + this.cogTiler = null; + throw error; + }); return this.cogTiler; } @@ -1250,10 +1264,37 @@ export class CesiumLayerSync { } this.applyHighlight(); this.watchCameraZoom(); + this.reorderKmlOverlays(); // Reordering two loaded CZML layers changes which one comes first. this.electCzmlClockOwner(); } + /** + * Re-append the KML ScreenOverlay containers in store order. `createKml` + * appends each container once, so a later panel reorder (which rebuilds + * nothing) would leave two overlapping overlays stacked the way they happened + * to load. Sibling DOM order decides that stacking, so re-appending in turn + * re-asserts it — skipped unless the order actually changed, since each pass + * moves live DOM nodes on a hot path. + */ + private reorderKmlOverlays(): void { + const containers: HTMLElement[] = []; + for (const layer of this.currentLayers) { + const container = this.entries.get(layer.id)?.overlayContainer; + if (container) containers.push(container); + } + const order = this.currentLayers + .filter((l) => this.entries.get(l.id)?.overlayContainer) + .map((l) => l.id) + .join("\n"); + if (order === this.lastKmlOverlayOrder) return; + this.lastKmlOverlayOrder = order; + for (const container of containers) container.parentElement?.appendChild(container); + } + + /** The KML overlay stacking order {@link reorderKmlOverlays} last asserted. */ + private lastKmlOverlayOrder = ""; + destroy(): void { this.restoreHighlight(); this.selection = null; @@ -1625,6 +1666,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 === "kml") void this.createKml(entry); else if (kind === "czml") void this.createCzml(entry); else if (kind === "pointcloud") void this.createPointCloud(entry); else if (kind === "points") this.createPointBatch(entry); @@ -2334,6 +2376,49 @@ export class CesiumLayerSync { } } + /** Load native KML/KMZ geometry, styles, overlays, and network links. */ + private async createKml(entry: LayerEntry): Promise { + const { Cesium: C, viewer } = this; + const source = cesiumKmlSource(entry.layer); + if (!source) return; + const container = document.createElement("div"); + Object.assign(container.style, { position: "absolute", inset: "0", pointerEvents: "none" }); + viewer.canvas.parentElement?.appendChild(container); + entry.overlayContainer = container; + const ds = new C.KmlDataSource({ camera: viewer.camera, canvas: viewer.canvas }); + try { + const target = source.startsWith("<") + ? new DOMParser().parseFromString(source, "application/xml") + : source.startsWith("data:") + ? await (await fetch(source)).blob() + : source; + if (entry.cancelled) { + ds.destroy(); + return; + } + await ds.load(target, { screenOverlayContainer: container }); + if (entry.cancelled) { + ds.destroy(); + return; + } + entry.handle = ds; + entry.documentCleanup = bindDocumentOpacity(C, ds, () => this.effectiveOpacity(entry)); + this.applyAppearance(entry); + await viewer.dataSources.add(ds); + if (entry.cancelled) { + viewer.dataSources.remove(ds, true); + return; + } + entry.added = true; + viewer.scene.requestRender(); + } catch (error) { + ds.destroy(); + container.remove(); + if (!entry.cancelled) + entry.loadError = error instanceof Error ? error.message : String(error); + } + } + /** * 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 @@ -2529,8 +2614,12 @@ export class CesiumLayerSync { (handle as DataSource).show = layer.visible; this.applyGeoJsonStyle(entry); this.applyGeoJsonFilter(entry); - } else if (entry.kind === "czml") { + } else if (entry.kind === "czml" || entry.kind === "kml") { (handle as DataSource).show = layer.visible; + if (entry.overlayContainer) { + entry.overlayContainer.style.display = layer.visible ? "" : "none"; + entry.overlayContainer.style.opacity = String(this.effectiveOpacity(entry)); + } } else if (entry.kind === "pointcloud") { const collection = handle as PointPrimitiveCollection; collection.show = layer.visible; @@ -3052,6 +3141,10 @@ export class CesiumLayerSync { private destroyEntry(entry: LayerEntry): void { entry.cancelled = true; entry.abort?.abort(); + entry.documentCleanup?.(); + entry.documentCleanup = undefined; + entry.overlayContainer?.remove(); + entry.overlayContainer = undefined; entry.fieldsListener?.(); entry.fieldsListener = undefined; this.storyOpacities.delete(entry.layer.id); @@ -3066,7 +3159,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" || entry.kind === "czml") { + } else if (entry.kind === "geojson" || entry.kind === "czml" || entry.kind === "kml") { // `cancelled` is already set, so the election skips this entry. if (this.czmlClockOwner === entry.layer.id) this.electCzmlClockOwner(); entry.cluster?.dispose(); diff --git a/packages/map/src/wasm-url.d.ts b/packages/map/src/wasm-url.d.ts new file mode 100644 index 0000000000..19847bd019 --- /dev/null +++ b/packages/map/src/wasm-url.d.ts @@ -0,0 +1,5 @@ +/** Bundlers expose explicitly imported WASM assets as served URLs. */ +declare module "*.wasm?url" { + const url: string; + export default url; +} diff --git a/packages/plugins/src/plugins/elevation-profile/cesium.ts b/packages/plugins/src/plugins/elevation-profile/cesium.ts new file mode 100644 index 0000000000..c558416565 --- /dev/null +++ b/packages/plugins/src/plugins/elevation-profile/cesium.ts @@ -0,0 +1,82 @@ +import type { Entity } from "@cesium/engine"; +import type { CesiumSceneHandle } from "@geolibre/map"; +import type { NativeProfileMap } from "./core/native"; + +/** Native profile geometry and unexaggerated samples from the active terrain. */ +export function cesiumProfileMap( + handle: CesiumSceneHandle, + fitBounds?: (bounds: [number, number, number, number]) => void, +): NativeProfileMap { + const { Cesium: C, viewer } = handle; + let line: Entity | undefined; + let hover: Entity | undefined; + const remove = (entity: Entity | undefined) => { + if (entity && !viewer.isDestroyed()) viewer.entities.remove(entity); + }; + return { + setLine(coords) { + remove(line); + line = undefined; + if (coords.length < 2 || viewer.isDestroyed()) return; + line = viewer.entities.add({ + polyline: { + positions: C.Cartesian3.fromDegreesArray(coords.flat()), + width: 3, + material: C.Color.fromCssColorString("#f97316"), + clampToGround: true, + }, + }); + handle.requestRender(); + }, + setHover(coord) { + remove(hover); + hover = undefined; + if (!coord || viewer.isDestroyed()) return; + hover = viewer.entities.add({ + position: C.Cartesian3.fromDegrees(...coord), + point: { + pixelSize: 12, + color: C.Color.RED, + outlineColor: C.Color.WHITE, + outlineWidth: 2, + heightReference: C.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Infinity, + }, + }); + handle.requestRender(); + }, + clear() { + remove(line); + remove(hover); + line = hover = undefined; + if (!viewer.isDestroyed()) handle.requestRender(); + }, + async sample(coords) { + if (viewer.isDestroyed()) throw new Error("The globe was closed."); + const terrain = viewer.terrainProvider; + const points = coords.map(([lng, lat]) => C.Cartographic.fromDegrees(lng, lat)); + const sampled = terrain.availability + ? await C.sampleTerrainMostDetailed(terrain, points) + : await C.sampleTerrain(terrain, 14, points); + if (viewer.isDestroyed() || viewer.terrainProvider !== terrain) + throw new Error("The terrain source changed. Draw the profile again."); + if (sampled.some((point) => !Number.isFinite(point.height))) + throw new Error("Terrain elevations are unavailable along this line."); + return sampled.map((point) => point.height); + }, + fit(coords) { + if (!coords.length) return; + const rectangle = C.Rectangle.fromCartographicArray( + coords.map(([lng, lat]) => C.Cartographic.fromDegrees(lng, lat)), + ); + const west = C.Math.toDegrees(rectangle.west), + east = C.Math.toDegrees(rectangle.east); + fitBounds?.([ + west, + C.Math.toDegrees(rectangle.south), + east < west ? east + 360 : east, + C.Math.toDegrees(rectangle.north), + ]); + }, + }; +} diff --git a/packages/plugins/src/plugins/elevation-profile/core/ElevationProfileControl.ts b/packages/plugins/src/plugins/elevation-profile/core/ElevationProfileControl.ts index bb3c0bd1e2..23127d2b3c 100644 --- a/packages/plugins/src/plugins/elevation-profile/core/ElevationProfileControl.ts +++ b/packages/plugins/src/plugins/elevation-profile/core/ElevationProfileControl.ts @@ -1,3 +1,4 @@ +import type { NativeProfileMap } from "./native"; import type { IControl, Map as MapLibreMap, MapMouseEvent, GeoJSONSource } from "maplibre-gl"; import type { Feature, FeatureCollection, LineString, Point } from "geojson"; @@ -54,7 +55,7 @@ const MAX_CHART_POINTS = 2000; const DEFAULT_OPTIONS: Required< Omit< ElevationProfileControlOptions, - "exportTextFile" | "getSelectedFeatures" | "onSelectionChange" + "exportTextFile" | "getSelectedFeatures" | "onSelectionChange" | "nativeMap" > > = { collapsed: true, @@ -99,9 +100,10 @@ export class ElevationProfileControl implements IControl, DeepLinkConsumer { private _options: Required< Omit< ElevationProfileControlOptions, - "exportTextFile" | "getSelectedFeatures" | "onSelectionChange" + "exportTextFile" | "getSelectedFeatures" | "onSelectionChange" | "nativeMap" > >; + private _nativeMap?: NativeProfileMap; private _exportTextFile?: ExportTextFile; private _getSelectedFeatures?: ElevationProfileControlOptions["getSelectedFeatures"]; private _onSelectionChange?: ElevationProfileControlOptions["onSelectionChange"]; @@ -151,7 +153,9 @@ export class ElevationProfileControl implements IControl, DeepLinkConsumer { * @param options - Optional configuration overrides */ constructor(options?: Partial) { - const { exportTextFile, getSelectedFeatures, onSelectionChange, ...visual } = options ?? {}; + const { nativeMap, exportTextFile, getSelectedFeatures, onSelectionChange, ...visual } = + options ?? {}; + this._nativeMap = nativeMap; this._exportTextFile = exportTextFile; this._getSelectedFeatures = getSelectedFeatures; this._onSelectionChange = onSelectionChange; @@ -329,7 +333,7 @@ export class ElevationProfileControl implements IControl, DeepLinkConsumer { this._drawing = true; this._drawVertices = []; this._map.getCanvas().style.cursor = "crosshair"; - this._map.doubleClickZoom.disable(); + if (!this._nativeMap) this._map.doubleClickZoom.disable(); this._map.on("click", this._onMapClick); this._map.on("dblclick", this._onMapDblClick); document.addEventListener("keydown", this._onKeyDown); @@ -344,7 +348,7 @@ export class ElevationProfileControl implements IControl, DeepLinkConsumer { } this._drawing = false; this._map.getCanvas().style.cursor = ""; - this._map.doubleClickZoom.enable(); + if (!this._nativeMap) this._map.doubleClickZoom.enable(); this._map.off("click", this._onMapClick); this._map.off("dblclick", this._onMapDblClick); document.removeEventListener("keydown", this._onKeyDown); @@ -460,7 +464,8 @@ export class ElevationProfileControl implements IControl, DeepLinkConsumer { const sampled = resampleLine(coords, this._options.maxSamples); try { - const elevations = await fetchElevations(sampled.coords); + const elevations = await (this._nativeMap?.sample(sampled.coords) ?? + fetchElevations(sampled.coords)); if (token !== this._requestToken) return; // superseded by a newer request this._sampledCoords = sampled.coords; @@ -473,8 +478,13 @@ export class ElevationProfileControl implements IControl, DeepLinkConsumer { this._renderProfile(); } catch (error) { if (token !== this._requestToken) return; + // The native sampler reports actionable conditions of its own ("the terrain + // source changed", "the globe was closed") as plain Errors; those messages + // are written for the user, so keep them instead of the HTTP-path fallback. const message = - error instanceof ElevationFetchError ? error.message : "Could not load elevation data."; + error instanceof ElevationFetchError || (this._nativeMap && error instanceof Error) + ? error.message + : "Could not load elevation data."; this._stats = null; this._profilePoints = []; this._setStatus(message); @@ -509,6 +519,7 @@ export class ElevationProfileControl implements IControl, DeepLinkConsumer { // --- Map layers -------------------------------------------------------- private _ensureMapLayers(): boolean { + if (this._nativeMap) return true; const map = this._map; if (!map) return false; if (!map.isStyleLoaded()) { @@ -569,6 +580,10 @@ export class ElevationProfileControl implements IControl, DeepLinkConsumer { } private _removeMapLayers(): void { + if (this._nativeMap) { + this._nativeMap.clear(); + return; + } const map = this._map; if (!map) return; for (const layer of [LAYER_HOVER, LAYER_VERTICES, LAYER_LINE]) { @@ -580,6 +595,10 @@ export class ElevationProfileControl implements IControl, DeepLinkConsumer { } private _setLineData(coords: LngLat[]): void { + if (this._nativeMap) { + this._nativeMap.setLine(coords); + return; + } const map = this._map; if (!map) return; const lineSource = map.getSource(SOURCE_LINE) as GeoJSONSource | undefined; @@ -609,6 +628,10 @@ export class ElevationProfileControl implements IControl, DeepLinkConsumer { } private _setHoverPoint(coord: LngLat | null): void { + if (this._nativeMap) { + this._nativeMap.setHover(coord); + return; + } const map = this._map; if (!map) return; const source = map.getSource(SOURCE_HOVER) as GeoJSONSource | undefined; @@ -617,6 +640,10 @@ export class ElevationProfileControl implements IControl, DeepLinkConsumer { } private _fitToLine(coords: LngLat[]): void { + if (this._nativeMap) { + this._nativeMap.fit(coords); + return; + } if (!this._map || coords.length === 0) return; // Unwrap longitudes so a line crossing the antimeridian (e.g. Bering Strait) // yields a tight box around the line rather than one spanning the globe. diff --git a/packages/plugins/src/plugins/elevation-profile/core/native.ts b/packages/plugins/src/plugins/elevation-profile/core/native.ts new file mode 100644 index 0000000000..6f9da1f269 --- /dev/null +++ b/packages/plugins/src/plugins/elevation-profile/core/native.ts @@ -0,0 +1,10 @@ +import type { LngLat } from "../elevation/geometry"; + +/** Renderer operations used by the profile panel, independent of MapLibre sources. */ +export interface NativeProfileMap { + setLine(coords: LngLat[]): void; + setHover(coord: LngLat | null): void; + clear(): void; + sample(coords: LngLat[]): Promise; + fit(coords: LngLat[]): void; +} diff --git a/packages/plugins/src/plugins/elevation-profile/core/types.ts b/packages/plugins/src/plugins/elevation-profile/core/types.ts index c7cc96f833..bbee1ac61c 100644 --- a/packages/plugins/src/plugins/elevation-profile/core/types.ts +++ b/packages/plugins/src/plugins/elevation-profile/core/types.ts @@ -2,6 +2,7 @@ import type { Feature, Geometry } from "geojson"; import type { LngLat } from "../elevation/geometry"; import type { UnitSystem } from "../elevation/format"; +import type { NativeProfileMap } from "./native"; /** Corner of the map the control can dock to. */ export type ControlPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"; @@ -36,6 +37,8 @@ export type ExportTextFile = ( /** Options for configuring the {@link ElevationProfileControl}. */ export interface ElevationProfileControlOptions { + /** Optional native renderer for profile geometry and terrain sampling. */ + nativeMap?: NativeProfileMap; /** Start collapsed (toggle button only). @default true */ collapsed?: boolean; /** Title shown in the panel header. @default 'Elevation Profile' */ diff --git a/packages/plugins/src/plugins/elevation-profile/index.ts b/packages/plugins/src/plugins/elevation-profile/index.ts index 36d4b19f3a..4104bf2037 100644 --- a/packages/plugins/src/plugins/elevation-profile/index.ts +++ b/packages/plugins/src/plugins/elevation-profile/index.ts @@ -1,5 +1,6 @@ import type { GeoLibreAppAPI, GeoLibreMapControlPosition, GeoLibrePlugin } from "../../types"; import { ElevationProfileControl } from "./core/ElevationProfileControl"; +import { cesiumProfileMap } from "./cesium"; import type { ElevationProfileState } from "./core/types"; import type { LngLat } from "./elevation/geometry"; import type { UnitSystem } from "./elevation/format"; @@ -11,10 +12,10 @@ import { ELEVATION_LINE_PARAM, maybeHandleDeepLink } from "./utils/deep-link"; * Adds a map control that lets the user draw a line and charts the elevation * profile along it — distance, ascent/descent, and min/max stats, a * metric/imperial toggle, hover readout, and CSV/SVG export — sampling - * elevations from the key-less Open-Meteo API. Ported in-house from the + * elevations from the active terrain on Cesium or the key-less Open-Meteo API + * on MapLibre. Ported in-house from the * external `geolibre-elevation-profile` marketplace plugin so it ships as a - * first-class built-in; the control code is unchanged, only the plugin entry is - * rebound onto GeoLibre's built-in `GeoLibrePlugin` contract. + * first-class built-in using GeoLibre's `GeoLibrePlugin` contract. * * The line, unit system, and collapsed state round-trip through the project * file, and a `?elevation-line=lng,lat;lng,lat` URL parameter restores a shared @@ -30,7 +31,9 @@ let position: GeoLibreMapControlPosition = "top-left"; let pendingState: Partial | null = null; function createControl(app: GeoLibreAppAPI): ElevationProfileControl { + const globe = app.getCesiumScene?.(); const next = new ElevationProfileControl({ + nativeMap: globe ? cesiumProfileMap(globe, app.fitBounds) : undefined, // The panel always mounts closed and `activate` opens it a tick later (see // there). Mounting it already expanded would show it for a frame before the // control's own click-outside handler saw the very click that enabled the @@ -96,6 +99,7 @@ function isPluginState(value: unknown): value is Partial export const maplibreElevationProfilePlugin: GeoLibrePlugin = { id: ELEVATION_PROFILE_PLUGIN_ID, + engines: ["maplibre", "cesium"], name: "Elevation Profile", version: "0.1.0", urlParameterNames: [ELEVATION_LINE_PARAM], diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index 31d8db0fd7..752c959216 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -2733,6 +2733,24 @@ def add_czml( _project.czml_layer(name, url=url, data=data, source_path=source_path, **style) ) + def add_cesium_kml( + self, + url: str | None = None, + name: str = "KML / KMZ", + *, + data: str | None = None, + source_path: str | None = None, + **style: Any, + ) -> str: + """Add native KML/KMZ on the globe, preserving document styling. + + Supply a URL, inline XML, or a KMZ data URL. Use ``add_kml`` for the + vector conversion that works on both rendering engines. + """ + return self._add_layer( + _project.cesium_kml_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 b2b8339107..177cbcf828 100644 --- a/python/src/geolibre/mcp/server.py +++ b/python/src/geolibre/mcp/server.py @@ -751,6 +751,21 @@ def add_czml_layer( layer = _project.czml_layer(name, url=url, data=data) return add(path, layer, index) + @tool() + def add_cesium_kml_layer( + path: str, + name: str, + url: str | None = None, + data: str | None = None, + index: int | None = None, + ) -> dict[str, Any]: + """Add native KML/KMZ with document styles, overlays, and network links. + + Supply a document URL, inline XML, or a KMZ data URL. Renders on the + globe only; set the project's primaryRenderer to "cesium". + """ + return add(path, _project.cesium_kml_layer(name, url=url, data=data), index) + # -- editing layers ------------------------------------------------------- @tool() diff --git a/python/src/geolibre/project.py b/python/src/geolibre/project.py index ccfe3268bc..da955d5533 100644 --- a/python/src/geolibre/project.py +++ b/python/src/geolibre/project.py @@ -1753,6 +1753,39 @@ def czml_layer( return layer +def cesium_kml_layer( + name: str, + *, + url: str | None = None, + data: str | None = None, + source_path: str | None = None, + **style: Any, +) -> dict[str, Any]: + """Build a native globe KML/KMZ layer preserving document styling. + + Supply a URL, inline KML XML, or a KMZ data URL. Package local resources + inside KMZ archives so they remain available when sharing the project. + """ + url = url.strip() if url else None + data = data.strip() if data else None + if not url and not data: + raise ValueError("Provide a KML/KMZ document or URL.") + layer = _layer_base(name, "3d-tiles", **style) + layer["source"] = { + "type": "3d-tiles", + "sourceId": layer["id"], + **({"kmlData": data} if data else {"url": url}), + } + if source_path: + layer["sourcePath"] = source_path + layer["metadata"] = { + "sourceKind": "cesium-kml", + "externalNativeLayer": True, + "identifiable": False, + } + return layer + + def video_layer( name: str, urls: list[str], diff --git a/python/tests/test_cesium_kml.py b/python/tests/test_cesium_kml.py new file mode 100644 index 0000000000..f6288f2334 --- /dev/null +++ b/python/tests/test_cesium_kml.py @@ -0,0 +1,29 @@ +"""Native KML documents survive Python project construction and serialization.""" + +import json + +import pytest + +import geolibre.geolibre as gmod +from geolibre import Map, project + + +@pytest.mark.parametrize( + "data", ["", "data:application/vnd.google-earth.kmz;base64,UEs="] +) +def test_native_kml_roundtrip(data, monkeypatch): + monkeypatch.setattr(gmod, "serve_app", lambda *_a, **_k: "http://127.0.0.1:0/") + monkeypatch.setattr(gmod, "app_port", lambda: 0) + m = Map() + layer_id = m.add_cesium_kml(data=data, source_path="landmarks.kmz") + layer = json.loads(json.dumps(m.to_project()))["layers"][-1] + assert layer["id"] == layer_id + assert layer["source"]["kmlData"] == data + assert layer["metadata"]["sourceKind"] == "cesium-kml" + assert layer["sourcePath"] == "landmarks.kmz" + + +@pytest.mark.parametrize("kwargs", [{}, {"url": " "}, {"data": "\n"}]) +def test_empty_document_rejected(kwargs): + with pytest.raises(ValueError, match="document or URL"): + project.cesium_kml_layer("Empty", **kwargs) diff --git a/python/tests/test_mcp_server.py b/python/tests/test_mcp_server.py index 4d24259803..2e31ca2f62 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_cesium_kml_layer", {"url": "https://example.com/landmarks.kmz"}, "3d-tiles"), + ("add_cesium_kml_layer", {"data": ""}, "3d-tiles"), ("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"), diff --git a/skills/geolibre/references/mcp-tools.md b/skills/geolibre/references/mcp-tools.md index eb2eb88801..3a8688144f 100644 --- a/skills/geolibre/references/mcp-tools.md +++ b/skills/geolibre/references/mcp-tools.md @@ -23,6 +23,7 @@ Pick by what the data **is**: | 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`. | +| Native KML/KMZ with document styling | `add_cesium_kml_layer` | 3D globe only. Supply `url`, inline XML in `data`, or a KMZ data URL. Package local icons and overlays in KMZ for sharing. | | 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 @@ -65,6 +66,7 @@ add_tiles_layer(path, name, url, kind="pmtiles", tile_type="vector", 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_cesium_kml_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/tests/cesium-control-host.test.ts b/tests/cesium-control-host.test.ts index f6d9c22334..a30a437fe1 100644 --- a/tests/cesium-control-host.test.ts +++ b/tests/cesium-control-host.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, it } from "node:test"; import { parseHTML } from "linkedom"; import { useAppStore } from "@geolibre/core"; import type { IControl, Map as MapLibreMap } from "maplibre-gl"; +import { Event as CesiumEvent } from "@cesium/engine"; import { CesiumControlHost, getPrimaryCesiumControlHost, @@ -355,10 +356,13 @@ describe("CesiumControlHost", () => { assert.throws(() => facade.setLayoutProperty(), /setLayoutProperty is not supported/); assert.throws(() => facade.getStyle(), /getStyle is not supported/); - // Source management works - facade.addSource("test-src", { type: "geojson" }); - assert.deepEqual(facade.getSource("test-src"), { type: "geojson" }); - facade.removeSource("test-src"); + // Source mutations must not report success without rendering anything. + assert.throws( + () => facade.addSource("test-src", { type: "geojson" }), + /addSource is not supported/, + ); + assert.throws(() => facade.removeSource("test-src"), /removeSource is not supported/); + assert.throws(() => facade.removeLayer("test-layer"), /removeLayer is not supported/); assert.equal(facade.getSource("test-src"), undefined); host.destroy(); @@ -377,6 +381,111 @@ describe("CesiumControlHost", () => { return facade; } + it("forwards camera events and detaches every subscription on destroy", () => { + const camera = Object.assign(viewer.camera, { + moveStart: new CesiumEvent(), + changed: new CesiumEvent(), + moveEnd: new CesiumEvent(), + }); + const host = new CesiumControlHost(viewer as never, parent); + const facade = facadeOf(host); + const events: string[] = []; + for (const name of ["movestart", "move", "moveend"]) facade.on(name, () => events.push(name)); + camera.moveStart.raiseEvent(); + camera.changed.raiseEvent(); + camera.moveEnd.raiseEvent(); + assert.deepEqual(events, ["movestart", "move", "moveend"]); + host.destroy(); + assert.equal(camera.moveStart.numberOfListeners, 0); + assert.equal(camera.changed.numberOfListeners, 0); + assert.equal(camera.moveEnd.numberOfListeners, 0); + }); + + it("forwards geographic pointer events and stops forwarding after destruction", () => { + const sceneViewer = makeSceneViewer(doc); + parent.appendChild(sceneViewer.canvas); + sceneViewer.canvas.getBoundingClientRect = () => ({ left: 10, top: 20 }) as DOMRect; + const host = new CesiumControlHost(sceneViewer as never, parent, makeFakeCesium() as never); + const facade = facadeOf(host); + assert.equal(facade.getContainer(), parent); + const clicks: any[] = []; + facade.on("click", (event: unknown) => clicks.push(event)); + const event = new doc.defaultView!.Event("click"); + Object.assign(event, { clientX: 110, clientY: 220 }); + sceneViewer.canvas.dispatchEvent(event); + assert.equal(clicks.length, 1); + assert.deepEqual([clicks[0].point.x, clicks[0].point.y], [100, 200]); + assert.ok(Math.abs(clicks[0].lngLat.lng - 12.5) < 1e-9); + assert.equal(clicks[0].originalEvent, event); + host.destroy(); + sceneViewer.canvas.dispatchEvent(event); + assert.equal(clicks.length, 1); + }); + + it("forwards a pointer event on a scene that has no globe", () => { + // `pickGlobeHit` answers a globe-less scene with a WGS84 ellipsoid pick, so + // the facade's cartographic conversion cannot assume `scene.globe` exists. + const canvas = doc.createElement("canvas"); + canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }) as DOMRect; + parent.appendChild(canvas); + const sceneViewer = { + canvas, + scene: { canvas }, + camera: { + heading: 0, + pitch: -Math.PI / 2, + getPickRay: (point: { x: number; y: number }) => ({ point }), + pickEllipsoid: () => ({ lng: 42, lat: -7 }), + }, + isDestroyed: () => false, + }; + const Cesium = makeFakeCesium(); + Cesium.Ellipsoid.WGS84 = { + cartesianToCartographic: (position: { lng: number; lat: number }) => ({ + longitude: position.lng * RADIANS, + latitude: position.lat * RADIANS, + }), + } as never; + const host = new CesiumControlHost(sceneViewer as never, parent, Cesium as never); + const facade = facadeOf(host); + const clicks: any[] = []; + facade.on("click", (event: unknown) => clicks.push(event)); + const event = new doc.defaultView!.Event("click"); + Object.assign(event, { clientX: 5, clientY: 6 }); + canvas.dispatchEvent(event); + assert.equal(clicks.length, 1); + assert.ok(Math.abs(clicks[0].lngLat.lng - 42) < 1e-9); + assert.ok(Math.abs(clicks[0].lngLat.lat - -7) < 1e-9); + host.destroy(); + }); + + it("skips the globe pick when no control listens for the event", () => { + // mousemove fires every pointer frame and each pick is a terrain ray + // intersection, so an unsubscribed event must not reach the scene at all. + const sceneViewer = makeSceneViewer(doc); + parent.appendChild(sceneViewer.canvas); + sceneViewer.canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }) as DOMRect; + let picks = 0; + const getPickRay = sceneViewer.camera.getPickRay; + sceneViewer.camera.getPickRay = (point: { x: number; y: number }) => { + picks++; + return getPickRay(point); + }; + const host = new CesiumControlHost(sceneViewer as never, parent, makeFakeCesium() as never); + const facade = facadeOf(host); + const move = new doc.defaultView!.Event("mousemove"); + Object.assign(move, { clientX: 5, clientY: 6 }); + sceneViewer.canvas.dispatchEvent(move); + assert.equal(picks, 0); + + const moves: unknown[] = []; + facade.on("mousemove", (event: unknown) => moves.push(event)); + sceneViewer.canvas.dispatchEvent(move); + assert.equal(picks, 1); + assert.equal(moves.length, 1); + host.destroy(); + }); + it("projects and unprojects through the Cesium scene", () => { const sceneViewer = makeSceneViewer(doc); const host = new CesiumControlHost(sceneViewer as never, parent, makeFakeCesium() as never); diff --git a/tests/cesium-elevation-profile.test.ts b/tests/cesium-elevation-profile.test.ts new file mode 100644 index 0000000000..3dffaa013f --- /dev/null +++ b/tests/cesium-elevation-profile.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import * as C from "@cesium/engine"; +import { cesiumProfileMap } from "../packages/plugins/src/plugins/elevation-profile/cesium"; + +function makeHandle() { + const calls: string[] = []; + const provider = { availability: {} }; + const viewer = { + entities: new C.EntityCollection(), + isDestroyed: () => false, + terrainProvider: provider, + }; + const sample = async (terrain: unknown, positions: C.Cartographic[]) => { + assert.equal(terrain, viewer.terrainProvider); + return positions.map((point, i) => { + point.height = 100 + i * 30; + return point; + }); + }; + const handle = { + Cesium: { + ...C, + sampleTerrainMostDetailed: (...args: Parameters) => { + calls.push("detailed"); + return sample(...args); + }, + sampleTerrain: (terrain: unknown, level: number, points: C.Cartographic[]) => { + calls.push(`level:${level}`); + return sample(terrain, points); + }, + }, + viewer, + requestRender() {}, + }; + return { handle, viewer, calls }; +} + +describe("Cesium elevation profiles", () => { + it("samples the active terrain at its most detailed level in unexaggerated metres", async () => { + const { handle, calls } = makeHandle(); + const profile = cesiumProfileMap(handle as never); + assert.deepEqual( + await profile.sample([ + [10, 20], + [10.1, 20.1], + ]), + [100, 130], + ); + assert.deepEqual(calls, ["detailed"]); + }); + it("uses a bounded level for terrain providers without availability metadata", async () => { + const { handle, viewer, calls } = makeHandle(); + viewer.terrainProvider = {} as never; + assert.deepEqual(await cesiumProfileMap(handle as never).sample([[10, 20]]), [100]); + assert.deepEqual(calls, ["level:14"]); + }); + it("draws native geometry, keeps unrelated entities, and fits across the antimeridian", () => { + const { handle, viewer } = makeHandle(); + viewer.entities.add({ id: "unrelated" }); + let bounds: number[] = []; + const profile = cesiumProfileMap(handle as never, (value) => { + bounds = value; + }); + profile.setLine([ + [179, 10], + [-179, 11], + ]); + profile.setHover([179, 10]); + assert.equal(viewer.entities.values.length, 3); + profile.fit([ + [179, 10], + [-179, 11], + ]); + assert.ok(bounds[2] - bounds[0] < 3); + profile.clear(); + assert.deepEqual( + viewer.entities.values.map((entity) => entity.id), + ["unrelated"], + ); + }); + it("rejects a sample if its terrain source changed during the request", async () => { + const { handle, viewer } = makeHandle(); + let finish!: (points: C.Cartographic[]) => void; + handle.Cesium.sampleTerrainMostDetailed = () => + new Promise((resolve) => { + finish = resolve; + }); + const request = cesiumProfileMap(handle as never).sample([[10, 20]]); + viewer.terrainProvider = { availability: {} }; + finish([C.Cartographic.fromDegrees(10, 20, 100)]); + await assert.rejects(request, /terrain source changed/); + }); +}); diff --git a/tests/cesium-kml.test.ts b/tests/cesium-kml.test.ts new file mode 100644 index 0000000000..67939a427b --- /dev/null +++ b/tests/cesium-kml.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import * as C from "@cesium/engine"; +import { parseHTML } from "linkedom"; +import { + createCesiumKmlLayer, + cesiumKmlSource, + isCesiumOnlyLayer, +} from "../packages/core/src/index"; +import { CesiumLayerSync, isCesiumSupportedLayerType } from "../packages/map/src/cesium-layer-sync"; +import { bindDocumentOpacity } from "../packages/map/src/cesium-document-opacity"; + +const originalDocument = globalThis.document; +afterEach(() => { + globalThis.document = originalDocument; +}); +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function globe(load: () => Promise = async () => {}) { + const { document } = parseHTML("
"); + globalThis.document = document; + const sources: NativeKml[] = []; + class NativeKml extends C.CustomDataSource { + destroyed = 0; + target: unknown; + options: unknown; + constructor() { + super(); + sources.push(this); + } + async load(target: unknown, options: unknown) { + this.target = target; + this.options = options; + await load(); + this.entities.add({ id: "landmark", point: { color: C.Color.RED.withAlpha(0.8) } }); + return this; + } + destroy() { + this.destroyed++; + } + } + const viewer = { + canvas: document.querySelector("canvas")!, + camera: { moveEnd: new C.Event(), changed: new C.Event() }, + clock: new C.Clock(), + dataSources: new C.DataSourceCollection(), + scene: { requestRender() {}, primitives: new C.PrimitiveCollection() }, + imageryLayers: { raiseToTop() {} }, + }; + const sync = new CesiumLayerSync( + { ...C, KmlDataSource: NativeKml } as never, + viewer as never, + () => 10, + ); + return { sync, viewer, sources }; +} + +describe("native KML documents", () => { + it("preserves inline XML and archive data URLs through a JSON project round trip", () => { + for (const data of [ + '', + "data:application/vnd.google-earth.kmz;base64,UEs=", + ]) { + const layer = JSON.parse(JSON.stringify(createCesiumKmlLayer({ name: "Document", data }))); + assert.equal(cesiumKmlSource(layer), data); + assert.equal(isCesiumOnlyLayer(layer), true); + assert.equal(isCesiumSupportedLayerType(layer), true); + } + assert.throws(() => createCesiumKmlLayer({ name: "Empty", url: " " }), /Provide/); + }); + + it("strips a byte order mark so inline XML is still recognized as a document", () => { + // A KML exported with a UTF-8 BOM must still take createKml's inline branch + // rather than being handed to KmlDataSource.load as a URL. `trim()` drops + // U+FEFF (ECMAScript counts as whitespace); pin that down so a + // future rewrite of the normalization keeps it. + const xml = ''; + const layer = createCesiumKmlLayer({ name: "BOM", data: `\uFEFF\n${xml}` }); + const source = cesiumKmlSource(layer)!; + assert.equal(source, xml); + assert.equal(source.startsWith("<"), true); + }); + + it("loads native documents, preserves colors while fading, and removes their overlays", async () => { + const { sync, viewer, sources } = globe(); + const layer = createCesiumKmlLayer({ name: "KML", url: "https://example.org/map.kmz" }); + sync.sync([layer]); + await flush(); + assert.equal(sources[0].target, layer.source.url); + assert.equal(viewer.dataSources.length, 1); + const point = sources[0].entities.getById("landmark")!.point!; + sync.sync([{ ...layer, opacity: 0.5 }]); + assert.equal(point.color!.getValue(viewer.clock.currentTime).alpha, 0.4); + sync.sync([{ ...layer, visible: false }]); + assert.equal(sources[0].show, false); + sync.sync([]); + assert.equal(viewer.dataSources.length, 0); + assert.equal(sources[0].destroyed, 1); + assert.equal(viewer.canvas.parentElement!.children.length, 1); + sync.destroy(); + }); + + it("destroys a document removed before its load completes", async () => { + let finish!: () => void; + const { sync, viewer, sources } = globe( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + sync.sync([createCesiumKmlLayer({ name: "Slow", url: "https://example.org/map.kml" })]); + sync.sync([]); + finish(); + await flush(); + assert.equal(viewer.dataSources.length, 0); + assert.equal(sources[0].destroyed, 1); + assert.equal(viewer.canvas.parentElement!.children.length, 1); + sync.destroy(); + }); + + it("reports loader failures and cleans up the overlay container", async () => { + const { sync, sources, viewer } = globe(async () => { + throw new Error("Invalid XML"); + }); + sync.sync([createCesiumKmlLayer({ name: "Broken", url: "https://example.org/broken.kml" })]); + await flush(); + assert.ok(JSON.stringify(sync.getRenderStatus()).includes("Invalid XML")); + assert.equal(sources[0].destroyed, 1); + assert.equal(viewer.canvas.parentElement!.children.length, 1); + sync.destroy(); + }); +}); + +it("native opacity retains time-varying colors and styles refreshed entities", () => { + const ds = new C.CustomDataSource(); + let alpha = 0.5; + const stop = bindDocumentOpacity(C, ds, () => alpha); + const original = new C.CallbackProperty( + (time) => (time!.secondsOfDay < 100 ? C.Color.RED : C.Color.BLUE), + false, + ); + const entity = ds.entities.add({ + point: { color: original }, + label: { text: "Landmark", fillColor: C.Color.YELLOW }, + }); + const time = new C.JulianDate(2451545, 0); + assert.equal(entity.point!.color!.getValue(time).red, 1); + assert.equal(entity.point!.color!.getValue(time).alpha, 0.5); + assert.equal(entity.label!.fillColor!.getValue(time).alpha, 0.5); + assert.equal(entity.label!.fillColor!.getValue(time).red, 1); + time.secondsOfDay = 200; + assert.equal(entity.point!.color!.getValue(time).blue, 1); + alpha = 1; + assert.equal(entity.point!.color!.getValue(time).alpha, 1); + stop(); + assert.equal(ds.entities.collectionChanged.numberOfListeners, 0); +});