Skip to content

fix(lidar): re-stream COPC point clouds when a saved project is reopened#877

Merged
giswqs merged 2 commits into
mainfrom
fix/issue-870-lidar-restore-on-reopen
Jun 25, 2026
Merged

fix(lidar): re-stream COPC point clouds when a saved project is reopened#877
giswqs merged 2 commits into
mainfrom
fix/issue-870-lidar-restore-on-reopen

Conversation

@giswqs

@giswqs giswqs commented Jun 25, 2026

Copy link
Copy Markdown
Member

Problem

Reopening a saved project that contains a LiDAR (COPC) layer shows the layer in the Layers panel but renders nothing on the map. Reported in #870.

Root cause: a lidar-url layer is restored into the store as inert metadata. The point cloud is streamed by the LiDAR control (loadPointCloud), not by the store layer sync, and there was no code path that re-streamed a restored layer. GeoLibre never called loadPointCloud outside the LiDAR panel's own UI, so a reopened project's point cloud was never fetched. (Verified in the real app: the layer is present, but no COPC request is issued and nothing renders, even for a CORS-friendly source.)

Fix

Add restoreLidarLayers, invoked from the same project-restore effect in DesktopShell that already re-runs restoreRasterLayers / restoreVectorLayers / restoreThreeDTilesLayers (gated on projectGeneration / mapReadyGeneration):

  • Finds lidar-url layers that are in the store but not yet loaded into the control, mounts the LiDAR control hidden (reveal: false, so the panel does not pop open), forces the Mercator projection the deck.gl point-cloud overlay needs (matching the USGS LiDAR plugin and the other deck.gl controls), and streams each one.
  • 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 instead of adding a duplicate.

Verification

Drove the real app with Playwright using a saved project that references the Autzen sample COPC:

  • Before: layer present in the panel, point cloud not rendered.
  • After: point cloud streams and renders on reopen, in both light and dark themes, with the existing visibility/opacity sync intact (single layer entry, no duplicate).
  • npm run build, npm run test:frontend (1612 pass), eslint, and pre-commit all green.

Fixes #870

Summary by CodeRabbit

  • New Features

    • Restored projects now automatically reload saved LiDAR point clouds after reopening, bringing LiDAR content back without manual reconfiguration.
    • LiDAR layer properties (name, visibility, opacity, style, grouping, and order) are preserved during restore.
  • Bug Fixes

    • Fixed LiDAR layers returning as metadata-only placeholders instead of fully restored point clouds.
    • Restoration now coordinates with plugin readiness to reliably reattach loaded LiDAR content and safely logs any restore issues.

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
@netlify

netlify Bot commented Jun 25, 2026

Copy link
Copy Markdown

Deploy Preview for geolibre-app ready!

Name Link
🔨 Latest commit 216314e
🔍 Latest deploy log https://app.netlify.com/projects/geolibre-app/deploys/6a3d69a7a2cf070008e6828a
😎 Deploy Preview https://deploy-preview-877--geolibre-app.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

DesktopShell now calls restoreLidarLayers after project restoration. The plugin adds hidden LiDAR control mounting, restore queuing, and load-event replacement so restored COPC layers re-stream with saved layer state.

Changes

LiDAR project restore flow

Layer / File(s) Summary
DesktopShell restore hook
apps/geolibre-desktop/src/components/layout/DesktopShell.tsx
DesktopShell imports restoreLidarLayers and calls it during project restoration after external plugins are ready.
Silent LiDAR restore setup
packages/plugins/src/index.ts, packages/plugins/src/plugins/maplibre-components.ts
restoreLidarLayers is added to the plugin surface, and the plugin adds pending restore tracking, silent control mounting, source URL lookup, and queued loadPointCloud calls.
Restore load swap and teardown cleanup
packages/plugins/src/plugins/maplibre-components.ts
The LiDAR load handler replaces restored placeholder layers with loaded point-cloud layers, and teardown clears pending restore state.

Sequence Diagram(s)

sequenceDiagram
  participant DesktopShell
  participant "restoreLidarLayers" as RestoreLidarLayers
  participant "openStandaloneLidarControl" as OpenStandaloneLidarControl
  participant LiDARControl
  participant "load event handler" as LoadEventHandler
  participant "app store" as AppStore
  DesktopShell->>RestoreLidarLayers: call with appAPI after project restore
  RestoreLidarLayers->>OpenStandaloneLidarControl: open with reveal=false
  RestoreLidarLayers->>LiDARControl: loadPointCloud(url)
  LiDARControl->>LoadEventHandler: emit load event
  LoadEventHandler->>AppStore: replace restored layer and apply visibility/opacity
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • opengeos/GeoLibre#330: Same project restoration path in DesktopShell; this PR adds a LiDAR-specific restore step that runs in that flow.

Poem

/)_/\
( •ㅅ• ) I hop through clouds of COPC light,
/   づ and tuck old layers back in sight.
Hidden control, then point clouds bloom—
a moonlit store of LiDAR zoom! 🐇

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The PR appears to address LiDAR restore on project reopen, but the summary doesn't show the underlying CORS-loading issue being fixed. Confirm that COPC loads from share.geolibre.app and the demo dataset now succeed in a fresh viewer session, not only on project restore.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: re-streaming COPC point clouds when reopening a saved project.
Out of Scope Changes check ✅ Passed The changes stay within LiDAR project-restore and plugin export work, with no obvious unrelated additions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-870-lidar-restore-on-reopen

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

⚡ Cloudflare Pages preview

Item Value
Preview https://aa21aacb.geolibre-preview.pages.dev
Demo app https://aa21aacb.geolibre-preview.pages.dev/demo/
Commit b04dcb9

Comment thread packages/plugins/src/plugins/maplibre-components.ts
Comment thread packages/plugins/src/plugins/maplibre-components.ts
Comment thread packages/plugins/src/plugins/maplibre-components.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

# Finding Confidence
1 lidarRestoreInFlight not cleared in teardownLidarControl — if teardown fires while restoreLidarLayers is suspended at await openStandaloneLidarControl, the flag stays true until the original finally block runs. Any restoreLidarLayers call that arrives between teardown and that finally (e.g. a new project load during map re-initialisation) silently returns early and never re-streams the LiDAR layers. Inline comment at teardownLidarControl with a one-line fix. High
2 URL-keyed pendingLidarRestores silently drops the second layer when two lidar-url layers share the same COPC endpointisLidarRestorePending sees the first entry and skips the second. Only one cloud is streamed; the second layer's inert placeholder stays in the Layers panel indefinitely. Inline comment at the guard inside the restore loop. Medium

Quality

# Finding Confidence
3 restoreKey as string cast — TypeScript cannot narrow restoreKey through the if (restore) guard so a cast is necessary, but the conventional spelling is a non-null assertion (restoreKey!). Inline comment with suggestion. Low (nit)

What I checked and found clean

  • restoreLidarLayers async structure: the try/finally correctly resets lidarRestoreInFlight on both success and failure paths; concurrent invocations are properly serialised.
  • Double-check against live store inside the loop (re-reading useAppStore.getState().layers after the async openStandaloneLidarControl returns): correct; stale-snapshot hazard is addressed.
  • createLidarLoadHandler restore path: removing the placeholder before adding the restored layer is synchronous, so the two-step remove+add cannot be interleaved. beforeLayerId is re-validated against the live store before addLayer — correct.
  • reveal: false hide logic in openStandaloneLidarControl: the created flag ensures the panel is hidden only when it was just created (not when it pre-existed), avoiding a flash for an already-visible control. lidarControl read inside the setTimeout callback refers to the module-level variable, so a null check via hideLidarControl(null) is safe.
  • Security / injection: no user-controlled strings are eval'd or injected into the DOM; the URL taken from layer.sourcePath / source.url is passed only to loadPointCloud (same trust boundary as the existing interactive flow).
  • CLAUDE.md: no new external map/tile hosts; no direct MapLibre mutations from UI; no missing i18n strings in the changed code.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src/components/layout/DesktopShell.tsx`:
- Line 821: The `DesktopShell` restore flow is swallowing a possible rejection
from `restoreLidarLayers(appAPI)`, so this `void` call can become an unhandled
promise rejection. Update the restore logic around `restoreLidarLayers` to
handle its rejection explicitly, either by awaiting it within the surrounding
async flow or by attaching a catch handler that logs and reports the failure.
Make sure the fix keeps the existing per-layer `loadPointCloud(...).catch(...)`
behavior intact while adding top-level failure handling for
`restoreLidarLayers`.

In `@packages/plugins/src/plugins/maplibre-components.ts`:
- Around line 3460-3473: The restore flow in maplibre-components is using
store.removeLayer() and store.addLayer(), which incorrectly flips isDirty and
records undo history during LiDAR placeholder replacement. Update the
restore-time replacement logic around the restored/restored.id block to use a
non-dirty swap path that replaces the layer in the store without changing the
current dirty/history state, preserving clean projects after the point cloud
loads.
- Around line 753-760: `PendingLidarRestore` and the LiDAR restore path are
dropping folder membership because `groupId` is not carried through, so reopened
layers get re-added ungrouped. Add `groupId` to `PendingLidarRestore`, thread it
through the restore logic that rebuilds the replacement layer, and ensure the
layer add/update call preserves the original group assignment in the relevant
LiDAR restore helpers.
- Line 761: The LiDAR restore tracking in maplibre-components is deduplicating
by URL only, so multiple saved layers that share the same COPC source cannot
both be restored. Update the pending restore state around pendingLidarRestores,
isLidarRestorePending(), and the restore handling paths to queue entries per URL
and distinguish them by layerId, then consume exactly one pending entry for each
load event so each placeholder layer can restore independently even when the URL
matches.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3796474d-b55f-46ff-9020-097b2dfbc44d

📥 Commits

Reviewing files that changed from the base of the PR and between 751f55a and a5f7a33.

📒 Files selected for processing (3)
  • apps/geolibre-desktop/src/components/layout/DesktopShell.tsx
  • packages/plugins/src/index.ts
  • packages/plugins/src/plugins/maplibre-components.ts

Comment thread apps/geolibre-desktop/src/components/layout/DesktopShell.tsx Outdated
Comment thread packages/plugins/src/plugins/maplibre-components.ts
Comment thread packages/plugins/src/plugins/maplibre-components.ts Outdated
Comment thread packages/plugins/src/plugins/maplibre-components.ts
- 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).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/plugins/src/plugins/maplibre-components.ts (1)

3486-3493: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Resolve beforeLayerId through restored placeholder IDs.

beforeLayerId stores the saved next layer’s placeholder ID, but restored LiDAR layers are re-added with fresh loadPointCloud IDs. If that next layer finishes first, its placeholder ID no longer exists, so this falls back to null and appends the current layer, losing saved layer order. Track savedPlaceholderId -> loadedLayerId during restore, or preserve the saved ID when re-adding, before resolving beforeLayerId.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/plugins/src/plugins/maplibre-components.ts` around lines 3486 -
3493, `beforeLayerId` resolution in the restore flow is using the saved
placeholder ID directly, but restored LiDAR layers get new IDs from
`loadPointCloud`, so ordering can be lost when the next layer restores first.
Update the restore logic around `store.addLayer(restored, beforeLayerId)` to
resolve `restore.beforeLayerId` through a `savedPlaceholderId -> loadedLayerId`
mapping, or preserve the original placeholder ID when re-adding restored layers,
then use that resolved ID when checking `useAppStore.getState().layers` and
inserting the layer.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/plugins/src/plugins/maplibre-components.ts`:
- Around line 3486-3493: `beforeLayerId` resolution in the restore flow is using
the saved placeholder ID directly, but restored LiDAR layers get new IDs from
`loadPointCloud`, so ordering can be lost when the next layer restores first.
Update the restore logic around `store.addLayer(restored, beforeLayerId)` to
resolve `restore.beforeLayerId` through a `savedPlaceholderId -> loadedLayerId`
mapping, or preserve the original placeholder ID when re-adding restored layers,
then use that resolved ID when checking `useAppStore.getState().layers` and
inserting the layer.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: aca6a58b-a8f1-4e1a-8387-8fc546eff6da

📥 Commits

Reviewing files that changed from the base of the PR and between a5f7a33 and 216314e.

📒 Files selected for processing (2)
  • apps/geolibre-desktop/src/components/layout/DesktopShell.tsx
  • packages/plugins/src/plugins/maplibre-components.ts

}
const restored: GeoLibreLayer = {
...layer,
name: restore.name || layer.name,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit – falsy coercion swallows intentional empty names

restore.name || layer.name uses ||, so an empty string (a user who cleared the layer name to "") would silently fall through to the URL-derived name. Prefer a nullish check:

Suggested change
name: restore.name || layer.name,
name: restore.name !== "" ? restore.name : layer.name,

Or if the type already ensures name is never null | undefined, just restore.name || layer.name is safe—but then the intent should be documented. Confidence: low.

Comment on lines +3480 to +3493
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium confidence – placeholder removal is gated, but addLayer is unconditional

The guard at lines 3480-3484 skips removeLayer if the saved placeholder was already removed (e.g. user deleted the layer during streaming). However, store.addLayer(restored, …) on line 3493 runs regardless, so the layer is silently resurrected even if the user explicitly deleted it.

The check also mixes two state snapshots: store.layers (captured at line 3453, before removeLayer) for the existence test, and a fresh useAppStore.getState().layers (line 3488-3491) for the beforeLayerId validation. Grabbing a fresh snapshot for the existence check too would make this consistent and rule out a TOCTOU between capture and use.

Suggested fix – bail out if the placeholder is gone (indicating a user deletion), using a single fresh read:

Suggested change
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);
const currentLayers = useAppStore.getState().layers;
const placeholderIndex = currentLayers.findIndex(
(item) => item.id === restore.layerId
);
if (placeholderIndex === -1) {
// Placeholder was removed while the point cloud was streaming; skip.
return;
}
store.removeLayer(restore.layerId);
const afterRemoveLayers = useAppStore.getState().layers;
const beforeLayerId =
restore.beforeLayerId &&
afterRemoveLayers.some((item) => item.id === restore.beforeLayerId)
? restore.beforeLayerId
: null;
store.addLayer(restored, beforeLayerId);

});
}
} finally {
lidarRestoreInFlight = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low confidence – flag is cleared while loadPointCloud calls are still in flight

lidarRestoreInFlight is reset in finally after all loadPointCloud calls are fired, not after they complete. A concurrent call to restoreLidarLayers that arrives after the finally runs (but before any streams finish) will pass the guard at line 2528 and proceed; individual layers are protected from re-queuing by isLidarRestorePending, but the flag itself no longer blocks the setup path.

This is fine functionally (the isLidarRestorePending check is the real per-layer guard), but the name lidarRestoreInFlight implies it covers the full restore lifecycle. A name like lidarRestoreSetupInProgress would better communicate that this only serialises the async setup step.

Comment on lines +3494 to +3498
if (!restored.visible) {
lidarLayerAdapter?.setVisibility(restored.id, false);
}
if (restored.opacity !== 1) {
lidarLayerAdapter?.setOpacity(restored.id, restored.opacity);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit – explicit adapter calls may be redundant with the store subscriber

createLidarControl sets up a store subscriber (via lidarStoreUnsubscribe) that already syncs visibility and opacity from the store to the adapter on every state change. Adding the layer via store.addLayer(restored, …) above triggers that subscriber, so these explicit setVisibility/setOpacity calls are likely no-ops that fire a millisecond after the subscriber already did the same work. If they're intentional (to guarantee synchronous application before the first render frame), a brief comment would help future readers understand why both paths exist.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed the diff (186 additions, 3 deletions across DesktopShell.tsx, maplibre-components.ts, and index.ts). The overall approach — queueing pending restores in a module-level FIFO map keyed by URL, then consuming the queue in the existing load event handler — is sound and well-matched to the event-driven LiDAR control architecture.


Bugs

Finding Confidence
Placeholder resurrection – in createLidarLoadHandler, store.addLayer(restored, …) (line 3493) runs unconditionally even if the saved placeholder was user-deleted while streaming. The removeLayer guard (lines 3480-3484) checks existence before removing, but there is no symmetric guard that skips addLayer when the placeholder is absent. A user who removes a LiDAR layer during the (potentially multi-second) streaming window will see it reappear. Inline comment filed with a suggested fix. Medium

Quality / Maintainability

Finding Confidence
lidarRestoreInFlight cleared too early – the flag is reset in finally after loadPointCloud calls are fired, not after they complete. The actual per-layer duplicate guard is isLidarRestorePending, not this flag, so the logic is correct; but the name implies broader coverage than it provides. A more precise name (lidarRestoreSetupInProgress) or a brief comment about its scope would help future readers. Inline comment filed. Low
name: restore.name || layer.name falsy coercion – an empty string name (user cleared the label) falls through to the URL-derived fallback. Low practical impact since the UI likely prevents empty names, but restore.name !== "" ? restore.name : layer.name makes intent explicit. Inline comment filed. Low
Redundant setVisibility/setOpacity calls – the store subscriber set up in createLidarControl already syncs these to the adapter when addLayer triggers a state change. The explicit calls at lines 3494-3498 are likely no-ops. If they're a deliberate synchronous guarantee, a comment would clarify. Inline comment filed. Low
Mixed state snapshots in createLidarLoadHandlerstore.layers (captured at line 3453) drives the existence check on line 3482, while a fresh useAppStore.getState().layers (line 3488) validates beforeLayerId. Harmless here (no awaits between capture and use), but inconsistent. Covered in the placeholder-resurrection inline comment. Low

Security / Performance

Nothing new introduced. The only external input touched (url / event.pointCloud.source) was already flowing through the LiDAR control before this PR.

CLAUDE.md

No violations found. New user-facing state (layer name, visibility, opacity) is not translatable copy, so no t() requirement. No new external hosts or CSP changes.


Overall: The fix correctly addresses the reported bug. The placeholder-resurrection edge case is the one finding worth considering before merge; the rest are low-severity nits.

@giswqs giswqs merged commit 395e5a9 into main Jun 25, 2026
33 checks passed
@giswqs giswqs deleted the fix/issue-870-lidar-restore-on-reopen branch June 25, 2026 19:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CORS error when loading COPC files from share.geolibre.app

1 participant