Skip to content

Commit a5f7a33

Browse files
committed
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
1 parent 751f55a commit a5f7a33

3 files changed

Lines changed: 164 additions & 3 deletions

File tree

apps/geolibre-desktop/src/components/layout/DesktopShell.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
restoreReverseGeocode,
1919
REVERSE_GEOCODE_PLUGIN_ID,
2020
restoreEffects,
21+
restoreLidarLayers,
2122
restoreRasterLayers,
2223
restoreThreeDTilesLayers,
2324
restoreVectorLayers,
@@ -813,6 +814,11 @@ export function DesktopShell({
813814
restoreThreeDTilesLayers(appAPI);
814815
restoreRasterLayers(appAPI);
815816
restoreVectorLayers(appAPI);
817+
// Re-stream saved LiDAR (COPC) point clouds. A `lidar-url` layer restores
818+
// into the store as inert metadata; the point cloud is loaded by the LiDAR
819+
// control, not the store, so without this the layer shows in the panel but
820+
// renders nothing.
821+
void restoreLidarLayers(appAPI);
816822
// Re-read drag-dropped / Add Data local-file GeoJSON layers from disk
817823
// (their data was saved as a path, not embedded).
818824
void restoreLocalFileLayers();

packages/plugins/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export {
8686
openHtmlPanel,
8787
openLegendPanel,
8888
openLidarLayerPanel,
89+
restoreLidarLayers,
8990
openMeasurePanel,
9091
openMinimapPanel,
9192
openPMTilesLayerPanel,

packages/plugins/src/plugins/maplibre-components.ts

Lines changed: 157 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,26 @@ let stacSearchStoreUnsubscribe: (() => void) | null = null;
741741
let zarrStoreUnsubscribe: (() => void) | null = null;
742742
let lidarStoreUnsubscribe: (() => void) | null = null;
743743
let splattingStoreUnsubscribe: (() => void) | null = null;
744+
745+
// Re-streaming saved LiDAR layers on project open. The store only holds a
746+
// `lidar-url` layer's metadata; the point cloud itself is loaded by the LiDAR
747+
// control, not the store, so a reopened project shows the layer in the panel
748+
// but renders nothing until we ask the control to stream it again (see
749+
// restoreLidarLayers). Because loadPointCloud assigns a fresh id, this map
750+
// carries the saved layer's desired state, keyed by source URL, so the load
751+
// handler can reattach the loaded cloud to the saved layer instead of adding a
752+
// duplicate.
753+
interface PendingLidarRestore {
754+
layerId: string;
755+
name: string;
756+
visible: boolean;
757+
opacity: number;
758+
style: GeoLibreLayer["style"];
759+
beforeLayerId: string | null;
760+
}
761+
const pendingLidarRestores = new Map<string, PendingLidarRestore>();
762+
let lidarRestoreInFlight = false;
763+
744764
let pluginActive = false;
745765
let componentsControlRevision = 0;
746766
let componentsConstructorsPromise: Promise<ComponentsConstructors> | null =
@@ -2433,13 +2453,20 @@ async function openStandaloneHtmlControl(
24332453
}
24342454

24352455
async function openStandaloneLidarControl(
2436-
app: GeoLibreAppAPI
2456+
app: GeoLibreAppAPI,
2457+
options: { reveal?: boolean } = {}
24372458
): Promise<boolean> {
2459+
// `reveal` shows and expands the panel (the default, for the Add LiDAR Layer
2460+
// menu action). Project restore mounts the control only to re-stream saved
2461+
// clouds, so it passes `reveal: false` to keep the panel out of the user's
2462+
// way; a freshly created control is hidden so it does not pop open on load.
2463+
const reveal = options.reveal ?? true;
24382464
const {
24392465
LidarControl: LidarControlClass,
24402466
LidarLayerAdapter: LidarLayerAdapterClass,
24412467
} = await getComponentsConstructors();
24422468

2469+
const created = !lidarControl;
24432470
lidarControl ??= createLidarControl(
24442471
LidarControlClass,
24452472
LidarLayerAdapterClass
@@ -2457,12 +2484,96 @@ async function openStandaloneLidarControl(
24572484
startLidarThemeSync();
24582485

24592486
setTimeout(() => {
2460-
showLidarControl(lidarControl);
2461-
lidarControl?.expand();
2487+
if (reveal) {
2488+
showLidarControl(lidarControl);
2489+
lidarControl?.expand();
2490+
} else if (created) {
2491+
hideLidarControl(lidarControl);
2492+
}
24622493
}, 0);
24632494
return true;
24642495
}
24652496

2497+
/**
2498+
* Read the source URL of a `lidar-url` layer, preferring the dedicated
2499+
* `sourcePath` and falling back to `source.url`.
2500+
*/
2501+
function lidarLayerUrl(layer: GeoLibreLayer): string | null {
2502+
if (typeof layer.sourcePath === "string" && layer.sourcePath) {
2503+
return layer.sourcePath;
2504+
}
2505+
const url = (layer.source as { url?: unknown }).url;
2506+
return typeof url === "string" && url ? url : null;
2507+
}
2508+
2509+
/** Whether a restore is already queued or in flight for this layer. */
2510+
function isLidarRestorePending(layer: GeoLibreLayer): boolean {
2511+
const url = lidarLayerUrl(layer);
2512+
if (url && pendingLidarRestores.has(url)) return true;
2513+
for (const pending of pendingLidarRestores.values()) {
2514+
if (pending.layerId === layer.id) return true;
2515+
}
2516+
return false;
2517+
}
2518+
2519+
/**
2520+
* Re-stream the point clouds for any restored `lidar-url` layers that are not
2521+
* yet loaded into the LiDAR control (e.g. after opening a saved project). The
2522+
* store only holds the layer metadata, so without this the layer appears in the
2523+
* Layers panel but renders nothing. The loaded cloud is reattached to the saved
2524+
* layer in {@link createLidarLoadHandler}, preserving its visibility, opacity,
2525+
* style, name, and position.
2526+
*/
2527+
export async function restoreLidarLayers(app: GeoLibreAppAPI): Promise<void> {
2528+
if (lidarRestoreInFlight) return;
2529+
2530+
const pending = useAppStore
2531+
.getState()
2532+
.layers.filter(
2533+
(layer) =>
2534+
isLidarControlLayer(layer) &&
2535+
!hasLidarPointCloud(layer.id) &&
2536+
!isLidarRestorePending(layer)
2537+
);
2538+
if (pending.length === 0) return;
2539+
2540+
lidarRestoreInFlight = true;
2541+
try {
2542+
const opened = await openStandaloneLidarControl(app, { reveal: false });
2543+
if (!opened || !lidarControl) return;
2544+
// The deck.gl point-cloud overlay only renders under the Mercator
2545+
// projection (the streaming loader's viewport math breaks under the default
2546+
// globe), matching the USGS LiDAR plugin and the other deck.gl controls.
2547+
ensureMercatorProjection(app.getMap?.());
2548+
2549+
for (const layer of pending) {
2550+
const url = lidarLayerUrl(layer);
2551+
if (!url) continue;
2552+
// Re-check against the live store: a layer may have been removed, already
2553+
// loaded, or queued while the control was loading asynchronously.
2554+
const current = useAppStore.getState().layers;
2555+
const index = current.findIndex((item) => item.id === layer.id);
2556+
if (index === -1) continue;
2557+
if (hasLidarPointCloud(layer.id) || isLidarRestorePending(layer)) continue;
2558+
2559+
pendingLidarRestores.set(url, {
2560+
layerId: layer.id,
2561+
name: layer.name,
2562+
visible: layer.visible,
2563+
opacity: layer.opacity,
2564+
style: layer.style,
2565+
beforeLayerId: current[index + 1]?.id ?? null,
2566+
});
2567+
lidarControl.loadPointCloud(url).catch((error: unknown) => {
2568+
pendingLidarRestores.delete(url);
2569+
console.warn("[lidar] failed to restore point cloud", url, error);
2570+
});
2571+
}
2572+
} finally {
2573+
lidarRestoreInFlight = false;
2574+
}
2575+
}
2576+
24662577
async function openStandaloneSplattingControl(
24672578
app: GeoLibreAppAPI
24682579
): Promise<boolean> {
@@ -3297,6 +3408,7 @@ function setHtmlPanelVisible(visible: boolean): void {
32973408

32983409
function teardownLidarControl(app: GeoLibreAppAPI): void {
32993410
stopLidarThemeSync();
3411+
pendingLidarRestores.clear();
33003412
lidarStoreUnsubscribe?.();
33013413
lidarStoreUnsubscribe = null;
33023414
lidarLayerAdapter?.destroy();
@@ -3326,6 +3438,48 @@ function createLidarLoadHandler(): LidarControlEventHandler {
33263438

33273439
const store = useAppStore.getState();
33283440
const layer = createLidarStoreLayer(event.pointCloud);
3441+
3442+
// Project restore: this load was triggered to re-stream a saved layer (see
3443+
// restoreLidarLayers). loadPointCloud assigns a fresh id, so swap the inert
3444+
// placeholder (saved id) for the loaded layer in place, carrying over the
3445+
// saved visibility, opacity, style, name, and position.
3446+
const restoreKey =
3447+
typeof event.pointCloud.source === "string"
3448+
? event.pointCloud.source
3449+
: null;
3450+
const restore = restoreKey ? pendingLidarRestores.get(restoreKey) : null;
3451+
if (restore) {
3452+
pendingLidarRestores.delete(restoreKey as string);
3453+
const restored: GeoLibreLayer = {
3454+
...layer,
3455+
name: restore.name || layer.name,
3456+
visible: restore.visible,
3457+
opacity: restore.opacity,
3458+
style: restore.style,
3459+
};
3460+
if (
3461+
restore.layerId !== restored.id &&
3462+
store.layers.some((item) => item.id === restore.layerId)
3463+
) {
3464+
store.removeLayer(restore.layerId);
3465+
}
3466+
const beforeLayerId =
3467+
restore.beforeLayerId &&
3468+
useAppStore
3469+
.getState()
3470+
.layers.some((item) => item.id === restore.beforeLayerId)
3471+
? restore.beforeLayerId
3472+
: null;
3473+
store.addLayer(restored, beforeLayerId);
3474+
if (!restored.visible) {
3475+
lidarLayerAdapter?.setVisibility(restored.id, false);
3476+
}
3477+
if (restored.opacity !== 1) {
3478+
lidarLayerAdapter?.setOpacity(restored.id, restored.opacity);
3479+
}
3480+
return;
3481+
}
3482+
33293483
if (store.layers.some((item) => item.id === layer.id)) {
33303484
store.updateLayer(layer.id, {
33313485
metadata: layer.metadata,

0 commit comments

Comments
 (0)