From a5f7a33718a24fe4782c8755823c51b728e3127c Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 25 Jun 2026 13:08:22 -0400 Subject: [PATCH 1/2] fix(lidar): re-stream COPC point clouds when a saved project is reopened A lidar (COPC) layer restores into the store as inert metadata: the point cloud is streamed by the LiDAR control, not the store, so reopening a saved project showed the layer in the Layers panel but rendered nothing. Add restoreLidarLayers, called from the same project-restore effect as the raster/vector/3D-tiles restorers, which streams each unloaded lidar-url layer via the LiDAR control (mounted hidden, Mercator forced for the deck.gl overlay as the other deck controls already do). Because loadPointCloud assigns a fresh id, the load handler reattaches the loaded cloud to the saved layer in place, preserving its visibility, opacity, style, name, and position rather than adding a duplicate. Fixes #870 --- .../src/components/layout/DesktopShell.tsx | 6 + packages/plugins/src/index.ts | 1 + .../src/plugins/maplibre-components.ts | 160 +++++++++++++++++- 3 files changed, 164 insertions(+), 3 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx b/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx index 00d1be5b..a1c80f3b 100644 --- a/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx +++ b/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx @@ -18,6 +18,7 @@ import { restoreReverseGeocode, REVERSE_GEOCODE_PLUGIN_ID, restoreEffects, + restoreLidarLayers, restoreRasterLayers, restoreThreeDTilesLayers, restoreVectorLayers, @@ -813,6 +814,11 @@ export function DesktopShell({ restoreThreeDTilesLayers(appAPI); restoreRasterLayers(appAPI); restoreVectorLayers(appAPI); + // Re-stream saved LiDAR (COPC) point clouds. A `lidar-url` layer restores + // into the store as inert metadata; the point cloud is loaded by the LiDAR + // control, not the store, so without this the layer shows in the panel but + // renders nothing. + void restoreLidarLayers(appAPI); // Re-read drag-dropped / Add Data local-file GeoJSON layers from disk // (their data was saved as a path, not embedded). void restoreLocalFileLayers(); diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index e4b68990..bdfbb610 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -86,6 +86,7 @@ export { openHtmlPanel, openLegendPanel, openLidarLayerPanel, + restoreLidarLayers, openMeasurePanel, openMinimapPanel, openPMTilesLayerPanel, diff --git a/packages/plugins/src/plugins/maplibre-components.ts b/packages/plugins/src/plugins/maplibre-components.ts index d8b45b21..b400c5c2 100644 --- a/packages/plugins/src/plugins/maplibre-components.ts +++ b/packages/plugins/src/plugins/maplibre-components.ts @@ -741,6 +741,26 @@ let stacSearchStoreUnsubscribe: (() => void) | null = null; let zarrStoreUnsubscribe: (() => void) | null = null; let lidarStoreUnsubscribe: (() => void) | null = null; let splattingStoreUnsubscribe: (() => void) | null = null; + +// Re-streaming saved LiDAR layers on project open. The store only holds a +// `lidar-url` layer's metadata; the point cloud itself is loaded by the LiDAR +// control, not the store, so a reopened project shows the layer in the panel +// but renders nothing until we ask the control to stream it again (see +// restoreLidarLayers). Because loadPointCloud assigns a fresh id, this map +// carries the saved layer's desired state, keyed by source URL, so the load +// handler can reattach the loaded cloud to the saved layer instead of adding a +// duplicate. +interface PendingLidarRestore { + layerId: string; + name: string; + visible: boolean; + opacity: number; + style: GeoLibreLayer["style"]; + beforeLayerId: string | null; +} +const pendingLidarRestores = new Map(); +let lidarRestoreInFlight = false; + let pluginActive = false; let componentsControlRevision = 0; let componentsConstructorsPromise: Promise | null = @@ -2433,13 +2453,20 @@ async function openStandaloneHtmlControl( } async function openStandaloneLidarControl( - app: GeoLibreAppAPI + app: GeoLibreAppAPI, + options: { reveal?: boolean } = {} ): Promise { + // `reveal` shows and expands the panel (the default, for the Add LiDAR Layer + // menu action). Project restore mounts the control only to re-stream saved + // clouds, so it passes `reveal: false` to keep the panel out of the user's + // way; a freshly created control is hidden so it does not pop open on load. + const reveal = options.reveal ?? true; const { LidarControl: LidarControlClass, LidarLayerAdapter: LidarLayerAdapterClass, } = await getComponentsConstructors(); + const created = !lidarControl; lidarControl ??= createLidarControl( LidarControlClass, LidarLayerAdapterClass @@ -2457,12 +2484,96 @@ async function openStandaloneLidarControl( startLidarThemeSync(); setTimeout(() => { - showLidarControl(lidarControl); - lidarControl?.expand(); + if (reveal) { + showLidarControl(lidarControl); + lidarControl?.expand(); + } else if (created) { + hideLidarControl(lidarControl); + } }, 0); return true; } +/** + * Read the source URL of a `lidar-url` layer, preferring the dedicated + * `sourcePath` and falling back to `source.url`. + */ +function lidarLayerUrl(layer: GeoLibreLayer): string | null { + if (typeof layer.sourcePath === "string" && layer.sourcePath) { + return layer.sourcePath; + } + const url = (layer.source as { url?: unknown }).url; + return typeof url === "string" && url ? url : null; +} + +/** Whether a restore is already queued or in flight for this layer. */ +function isLidarRestorePending(layer: GeoLibreLayer): boolean { + const url = lidarLayerUrl(layer); + if (url && pendingLidarRestores.has(url)) return true; + for (const pending of pendingLidarRestores.values()) { + if (pending.layerId === layer.id) return true; + } + return false; +} + +/** + * Re-stream the point clouds for any restored `lidar-url` layers that are not + * yet loaded into the LiDAR control (e.g. after opening a saved project). The + * store only holds the layer metadata, so without this the layer appears in the + * Layers panel but renders nothing. The loaded cloud is reattached to the saved + * layer in {@link createLidarLoadHandler}, preserving its visibility, opacity, + * style, name, and position. + */ +export async function restoreLidarLayers(app: GeoLibreAppAPI): Promise { + if (lidarRestoreInFlight) return; + + const pending = useAppStore + .getState() + .layers.filter( + (layer) => + isLidarControlLayer(layer) && + !hasLidarPointCloud(layer.id) && + !isLidarRestorePending(layer) + ); + if (pending.length === 0) return; + + lidarRestoreInFlight = true; + try { + const opened = await openStandaloneLidarControl(app, { reveal: false }); + if (!opened || !lidarControl) return; + // The deck.gl point-cloud overlay only renders under the Mercator + // projection (the streaming loader's viewport math breaks under the default + // globe), matching the USGS LiDAR plugin and the other deck.gl controls. + ensureMercatorProjection(app.getMap?.()); + + for (const layer of pending) { + const url = lidarLayerUrl(layer); + if (!url) continue; + // Re-check against the live store: a layer may have been removed, already + // loaded, or queued while the control was loading asynchronously. + const current = useAppStore.getState().layers; + const index = current.findIndex((item) => item.id === layer.id); + if (index === -1) continue; + if (hasLidarPointCloud(layer.id) || isLidarRestorePending(layer)) continue; + + pendingLidarRestores.set(url, { + layerId: layer.id, + name: layer.name, + visible: layer.visible, + opacity: layer.opacity, + style: layer.style, + beforeLayerId: current[index + 1]?.id ?? null, + }); + lidarControl.loadPointCloud(url).catch((error: unknown) => { + pendingLidarRestores.delete(url); + console.warn("[lidar] failed to restore point cloud", url, error); + }); + } + } finally { + lidarRestoreInFlight = false; + } +} + async function openStandaloneSplattingControl( app: GeoLibreAppAPI ): Promise { @@ -3297,6 +3408,7 @@ function setHtmlPanelVisible(visible: boolean): void { function teardownLidarControl(app: GeoLibreAppAPI): void { stopLidarThemeSync(); + pendingLidarRestores.clear(); lidarStoreUnsubscribe?.(); lidarStoreUnsubscribe = null; lidarLayerAdapter?.destroy(); @@ -3326,6 +3438,48 @@ function createLidarLoadHandler(): LidarControlEventHandler { const store = useAppStore.getState(); const layer = createLidarStoreLayer(event.pointCloud); + + // Project restore: this load was triggered to re-stream a saved layer (see + // restoreLidarLayers). loadPointCloud assigns a fresh id, so swap the inert + // placeholder (saved id) for the loaded layer in place, carrying over the + // saved visibility, opacity, style, name, and position. + const restoreKey = + typeof event.pointCloud.source === "string" + ? event.pointCloud.source + : null; + const restore = restoreKey ? pendingLidarRestores.get(restoreKey) : null; + if (restore) { + pendingLidarRestores.delete(restoreKey as string); + const restored: GeoLibreLayer = { + ...layer, + name: restore.name || layer.name, + visible: restore.visible, + opacity: restore.opacity, + style: restore.style, + }; + if ( + restore.layerId !== restored.id && + store.layers.some((item) => item.id === restore.layerId) + ) { + store.removeLayer(restore.layerId); + } + const beforeLayerId = + restore.beforeLayerId && + useAppStore + .getState() + .layers.some((item) => item.id === restore.beforeLayerId) + ? restore.beforeLayerId + : null; + store.addLayer(restored, beforeLayerId); + if (!restored.visible) { + lidarLayerAdapter?.setVisibility(restored.id, false); + } + if (restored.opacity !== 1) { + lidarLayerAdapter?.setOpacity(restored.id, restored.opacity); + } + return; + } + if (store.layers.some((item) => item.id === layer.id)) { store.updateLayer(layer.id, { metadata: layer.metadata, From 216314e427ea292a33bc4ccbdf40a2fd9ce4eadd Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 25 Jun 2026 13:37:22 -0400 Subject: [PATCH 2/2] Address Claude and CodeRabbit review feedback - Reset lidarRestoreInFlight on teardownLidarControl so a teardown while a restore is awaiting the control cannot strand the guard and block later restores (Claude). - Support two saved LiDAR layers that reference the same COPC URL: key pendingLidarRestores by URL with a FIFO queue, match each layer by layerId, and consume one entry per load event so neither placeholder is left inert (Claude, CodeRabbit). - Preserve groupId so a LiDAR layer saved inside a folder restores into that folder instead of at the top level (CodeRabbit). - Catch restoreLidarLayers rejections at the DesktopShell call site so a failure is logged, not an unhandled rejection (CodeRabbit). - Use a non-null assertion instead of `as string` for the narrowed restoreKey (Claude nit). --- .../src/components/layout/DesktopShell.tsx | 4 +- .../src/plugins/maplibre-components.ts | 52 +++++++++++++------ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx b/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx index a1c80f3b..2abfb4e9 100644 --- a/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx +++ b/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx @@ -818,7 +818,9 @@ export function DesktopShell({ // into the store as inert metadata; the point cloud is loaded by the LiDAR // control, not the store, so without this the layer shows in the panel but // renders nothing. - void restoreLidarLayers(appAPI); + void restoreLidarLayers(appAPI).catch((error: unknown) => { + console.warn("[lidar] failed to restore saved point clouds", error); + }); // Re-read drag-dropped / Add Data local-file GeoJSON layers from disk // (their data was saved as a path, not embedded). void restoreLocalFileLayers(); diff --git a/packages/plugins/src/plugins/maplibre-components.ts b/packages/plugins/src/plugins/maplibre-components.ts index b400c5c2..fa4eaccd 100644 --- a/packages/plugins/src/plugins/maplibre-components.ts +++ b/packages/plugins/src/plugins/maplibre-components.ts @@ -746,19 +746,21 @@ let splattingStoreUnsubscribe: (() => void) | null = null; // `lidar-url` layer's metadata; the point cloud itself is loaded by the LiDAR // control, not the store, so a reopened project shows the layer in the panel // but renders nothing until we ask the control to stream it again (see -// restoreLidarLayers). Because loadPointCloud assigns a fresh id, this map -// carries the saved layer's desired state, keyed by source URL, so the load -// handler can reattach the loaded cloud to the saved layer instead of adding a -// duplicate. +// restoreLidarLayers). Because loadPointCloud assigns a fresh id, each entry +// carries the saved layer's desired state so the load handler can reattach the +// loaded cloud to the saved layer instead of adding a duplicate. The map is +// keyed by source URL and holds a FIFO queue per URL, so two saved layers that +// point at the same COPC file both restore (one entry consumed per load event). interface PendingLidarRestore { layerId: string; name: string; visible: boolean; opacity: number; style: GeoLibreLayer["style"]; + groupId: string | undefined; beforeLayerId: string | null; } -const pendingLidarRestores = new Map(); +const pendingLidarRestores = new Map(); let lidarRestoreInFlight = false; let pluginActive = false; @@ -2506,12 +2508,10 @@ function lidarLayerUrl(layer: GeoLibreLayer): string | null { return typeof url === "string" && url ? url : null; } -/** Whether a restore is already queued or in flight for this layer. */ +/** Whether a restore is already queued or in flight for this specific layer. */ function isLidarRestorePending(layer: GeoLibreLayer): boolean { - const url = lidarLayerUrl(layer); - if (url && pendingLidarRestores.has(url)) return true; - for (const pending of pendingLidarRestores.values()) { - if (pending.layerId === layer.id) return true; + for (const queue of pendingLidarRestores.values()) { + if (queue.some((pending) => pending.layerId === layer.id)) return true; } return false; } @@ -2556,16 +2556,27 @@ export async function restoreLidarLayers(app: GeoLibreAppAPI): Promise { if (index === -1) continue; if (hasLidarPointCloud(layer.id) || isLidarRestorePending(layer)) continue; - pendingLidarRestores.set(url, { + const entry: PendingLidarRestore = { layerId: layer.id, name: layer.name, visible: layer.visible, opacity: layer.opacity, style: layer.style, + groupId: layer.groupId, beforeLayerId: current[index + 1]?.id ?? null, - }); + }; + const queue = pendingLidarRestores.get(url); + if (queue) queue.push(entry); + else pendingLidarRestores.set(url, [entry]); lidarControl.loadPointCloud(url).catch((error: unknown) => { - pendingLidarRestores.delete(url); + // Drop only this layer's entry so a sibling restore for the same URL is + // not lost; clean up the map key once its queue empties. + const remaining = pendingLidarRestores.get(url); + if (remaining) { + const at = remaining.indexOf(entry); + if (at !== -1) remaining.splice(at, 1); + if (remaining.length === 0) pendingLidarRestores.delete(url); + } console.warn("[lidar] failed to restore point cloud", url, error); }); } @@ -3408,7 +3419,10 @@ function setHtmlPanelVisible(visible: boolean): void { function teardownLidarControl(app: GeoLibreAppAPI): void { stopLidarThemeSync(); + // Clear restore bookkeeping so a teardown mid-restore (project reload, map + // re-init) cannot strand the in-flight guard and block later restores. pendingLidarRestores.clear(); + lidarRestoreInFlight = false; lidarStoreUnsubscribe?.(); lidarStoreUnsubscribe = null; lidarLayerAdapter?.destroy(); @@ -3447,15 +3461,21 @@ function createLidarLoadHandler(): LidarControlEventHandler { typeof event.pointCloud.source === "string" ? event.pointCloud.source : null; - const restore = restoreKey ? pendingLidarRestores.get(restoreKey) : null; - if (restore) { - pendingLidarRestores.delete(restoreKey as string); + const restoreQueue = restoreKey + ? pendingLidarRestores.get(restoreKey) + : undefined; + const restore = restoreQueue?.shift(); + if (restore && restoreKey) { + if (restoreQueue && restoreQueue.length === 0) { + pendingLidarRestores.delete(restoreKey); + } const restored: GeoLibreLayer = { ...layer, name: restore.name || layer.name, visible: restore.visible, opacity: restore.opacity, style: restore.style, + ...(restore.groupId ? { groupId: restore.groupId } : {}), }; if ( restore.layerId !== restored.id &&