Skip to content

40 visualise multiple point clouds simultaneously #41

Merged
merged 10 commits into from
Oct 27, 2025
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Molloy Explorer is a 3D seabed visualization tool built with **Potree**. It allo

### Add point cloud data

Place the point cloud data (in Potree format with EPSG:4978 coordinates) in `public/pointclouds/data_converted`.
Place the point cloud data (in Potree format with EPSG:4978 coordinates) in `public/pointclouds`. Ensure the point cloud folder names match the paths specified in `src/config.js`, either by renaming the point cloud folders or by updating the paths.

**Note:** Point cloud files should not be committed to Git.

Expand Down
36 changes: 24 additions & 12 deletions src/AcceptedFiltering/threePanels.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ function insertSection({ headerId, headerText, listId }) {
}

accordionRefresh()

header.addEventListener('click', () => {
const $ = window.jQuery || window.$
if ($ && $.fn?.accordion && $(menu).data('uiAccordion')) return
if ($) {
const $p = $(panel)
$p.is(':visible') ? $p.slideUp(350) : $p.slideDown(350)
return
}
panel.style.display = panel.style.display === 'none' ? '' : 'none'
})
}

/**
Expand Down Expand Up @@ -181,10 +192,10 @@ function ensureElevationButton(hooks) {
}

/**
* Reconnects the Elevation slider label to reflect the current range.
* Assumes #sldHeightRange and #lblHeightRange exist.
* Sets up a elevation range slider for interactive updates
* @param {{onElevationRangeChange?:Function}} hooks
*/
function rebindElevationLabel() {
function setUpElevationSlider(hooks) {
const $ = window.jQuery || window.$
const slider = $ ? $('#sldHeightRange') : null
const label = byId('lblHeightRange')
Expand All @@ -194,26 +205,27 @@ function rebindElevationLabel() {
const low = slider.slider('values', 0)
const high = slider.slider('values', 1)
label.textContent = `${low.toFixed(2)} to ${high.toFixed(2)}`
hooks?.onElevationRangeChange([low, high])
}

slider.slider({ min: -10000, max: 0, values: [-10000, 0] })
slider.off('slide.custom slidestop.custom change.custom')
slider.on('slide.custom', 'slidestop.custom change.custom', update)
slider.on('slide.custom slidestop.custom change.custom', update)
update()
}

/**
* Moves Potree's Elevation container under the Elevation panel body and rebinds label.
* @returns {boolean} true if moved or already in place
*/
function moveElevationContainer() {
function moveElevationContainer(hooks) {
const { body } = ensurePanelScaffold('elevation2_list')
const src = byId('materials.elevation_container')
if (!body || !src) return false

if (src.parentNode !== body) {
body.appendChild(src)
rebindElevationLabel()
setUpElevationSlider(hooks)
accordionRefresh()
}
return true
Expand All @@ -229,7 +241,7 @@ function initElevationControls(hooks) {

const root = byId('potree_menu') || document.body
const obs = new MutationObserver(() => {
if (byId('materials.elevation_container')) moveElevationContainer()
if (byId('materials.elevation_container')) moveElevationContainer(hooks)
})
obs.observe(root, { childList: true, subtree: true })
}
Expand Down Expand Up @@ -362,7 +374,7 @@ async function ensurePanelCaptured(mode, hooks) {
selectCloudNode(hooks)
src = await waitForOrPoll('materials.elevation_container', 1800)
}
if (src) moveElevationContainer()
if (src) moveElevationContainer(hooks)
return
}
}
Expand Down Expand Up @@ -394,14 +406,14 @@ async function switchMode(mode, hook, hooksBag = {}) {
* @param {()=>'elevation'|'accepted'} activeGetter
* @returns {MutationObserver}
*/
function attachSelfHealing(activeGetter) {
function attachSelfHealing(activeGetter, hooks) {
const root = byId('potree_menu') || document.body
const obs = new MutationObserver(() => {
const mode = activeGetter()
if (mode === 'elevation') {
const src = byId('materials.elevation_container')
const { body } = ensurePanelScaffold('elevation2_list')
if (src && body && src.parentNode !== body) moveElevationContainer()
if (src && body && src.parentNode !== body) moveElevationContainer(hooks)
}
})
obs.observe(root, { childList: true, subtree: true })
Expand All @@ -412,7 +424,7 @@ function attachSelfHealing(activeGetter) {
/**
* Public entrypoint: builds Elevation and Accepted panels and wires behavior.
* @param {object} viewer Potree viewer (not used directly here but available to hooks)
* @param {{onActivateElevation?:Function, onActivateAccepted?:Function, selectCloudOnce?:Function}} hooks
* @param {{onActivateElevation?:Function, onActivateAccepted?:Function, selectCloudOnce?:Function, onElevationRangeChange?:Function}} hooks
*/
export function initThreePanels(viewer, hooks = {}) {
// Build sections
Expand All @@ -433,7 +445,7 @@ export function initThreePanels(viewer, hooks = {}) {
setActive('accepted')
)

attachSelfHealing(getActive)
attachSelfHealing(getActive, hooks)

// Default: auto-activate Elevation once
clickOnce('btnDoElevationControl')
Expand Down
8 changes: 5 additions & 3 deletions src/AnnotationControl/annotationPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ export function initAnnotationsPanel(viewer) {

// Toggle collapse
header.addEventListener('click', () => {
if ($(menu).accordion && $(menu).data('uiAccordion')) return
if (window.jQuery) {
const $p = window.jQuery(panel)
const $ = window.jQuery || window.$
if ($ && $.fn?.accordion && $(menu).data('uiAccordion')) return
if ($) {
const $p = $(panel)
$p.is(':visible') ? $p.slideUp(350) : $p.slideDown(350)
return
}
panel.style.display = panel.style.display === 'none' ? '' : 'none'
})
targetContainer = panel.querySelector('#annotations_list')
}
Expand Down
18 changes: 17 additions & 1 deletion src/config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
export const POTREE_POINTCLOUD_URL = '/pointclouds/data_converted/metadata.json'
export const POTREE_POINTCLOUD_URLS = [
'/pointclouds/cell_1/metadata.json',
'/pointclouds/cell_2/metadata.json',
'/pointclouds/cell_3/metadata.json',
'/pointclouds/cell_4/metadata.json',
'/pointclouds/cell_5/metadata.json',
'/pointclouds/cell_6/metadata.json',
'/pointclouds/cell_7/metadata.json',
'/pointclouds/cell_8/metadata.json',
'/pointclouds/cell_9/metadata.json',
'/pointclouds/cell_10/metadata.json',
'/pointclouds/cell_11/metadata.json',
'/pointclouds/cell_12/metadata.json',
'/pointclouds/cell_13/metadata.json',
'/pointclouds/cell_14/metadata.json',
'/pointclouds/cell_15/metadata.json'
]

export const POTREE_SETTINGS = {
edl: true,
Expand Down
4 changes: 2 additions & 2 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { POTREE_POINTCLOUD_URL, POTREE_SETTINGS } from './config.js'
import { POTREE_POINTCLOUD_URLS, POTREE_SETTINGS } from './config.js'
import { createCesiumViewer, loadCountryBorders } from './cesiumViewer.js'
import { createPotreeViewer } from './potreeViewer.js'
import { syncCameras } from './cameraSync.js'
Expand All @@ -18,7 +18,7 @@ async function init() {

window.potreeViewer = await createPotreeViewer(
'potree_render_area',
POTREE_POINTCLOUD_URL,
POTREE_POINTCLOUD_URLS,
POTREE_SETTINGS
)

Expand Down
148 changes: 117 additions & 31 deletions src/potreeViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ import { ecef } from './config.js'
* Initializes the Potree viewer used to visualize the point cloud.
*
* @param containerId - id of the container
* @param pointcloudUrl - url path to the point cloud
* @param pointcloudUrls - url paths to the point clouds
* @param settings - other settings
* @returns Potree viewer
*/
export async function createPotreeViewer(containerId, pointcloudUrl, settings) {
export async function createPotreeViewer(
containerId,
pointcloudUrls,
settings
) {
const viewer = new Potree.Viewer(document.getElementById(containerId), {
useDefaultRenderLoop: false
})
Expand All @@ -31,6 +35,26 @@ export async function createPotreeViewer(containerId, pointcloudUrl, settings) {
viewer.loadSettingsFromURL()
viewer.setDescription('Molloy Explorer')

const pointclouds = []
for (const url of pointcloudUrls) {
const e = await Potree.loadPointCloud(url)
const pc = e.pointcloud
viewer.scene.addPointCloud(pc)

pc.material.pointSizeType = Potree.PointSizeType.ADAPTIVE
pc.material.shape = Potree.PointShape.CIRCLE
overrideShaderForGradient(pc)

//The default activeAttributeName is set to elevation and the color gradient to VIRIDIS for good visualization
pc.material.elevationRange = [-10000, 0]
pc.material.activeAttributeName = 'elevation'
pc.material.gradient = Potree.Gradients['VIRIDIS']

e.pointcloud.projection = ecef

pointclouds.push(pc)
}

viewer.loadGUI(() => {
viewer.setLanguage('en')
$('#menu_appearance').next().show()
Expand All @@ -39,54 +63,71 @@ export async function createPotreeViewer(containerId, pointcloudUrl, settings) {
$('#menu_filters').next().show()
viewer.toggleSidebar()

// Store the last used elevation gradient
let lastElevationGradient = 'VIRIDIS'

// Helper function to update all point clouds' elevation range
function updateAllCloudsElevation(range) {
pointclouds.forEach((pc) => {
pc.material.activeAttributeName = 'elevation'
pc.material.elevationRange = range
})
}

// Helper function to update all point clouds' gradient
function updateAllCloudsGradient(gradientName) {
pointclouds.forEach((pc) => {
pc.material.gradient = Potree.Gradients[gradientName]
})
}

// Helper function to update all point clouds for Accepted filtering
function updateAllCloudsAccepted(gradientName) {
pointclouds.forEach((pc) => {
pc.material.activeAttributeName = 'Accepted'
pc.material.gradient = Potree.Gradients[gradientName]
})
}

initThreePanels(viewer, {
onActivateElevation: () => {
if (!pc) return
pc.material.activeAttributeName = 'elevation'
pc.material.gradient = Potree.Gradients['VIRIDIS']
const $ = window.jQuery || window.$
const slider = $ ? $('#sldHeightRange') : null
const values = slider?.slider('values') ?? []
const low = typeof values[0] === 'number' ? values[0] : -10000
const high = typeof values[1] === 'number' ? values[1] : 0

updateAllCloudsElevation([low, high])
updateAllCloudsGradient(lastElevationGradient)
suppressSidebarAutoScroll(clickCloudIconOnce)
},
onActivateAccepted: () => {
if (!pc) return
pc.material.activeAttributeName = 'accepted'
pc.material.gradient = Potree.Gradients['GRAYSCALE']
updateAllCloudsAccepted('GRAYSCALE')
toggleAcceptedLegend(true)
suppressSidebarAutoScroll(clickCloudIconOnce)
}
},
onElevationRangeChange: updateAllCloudsElevation
})
// // // helper

// helper
function clickCloudIconOnce() {
const icon = document.querySelector(
'#scene_objects i.jstree-themeicon-custom'
)
if (icon) icon.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}

function setLastElevationGradient(gradientName) {
lastElevationGradient = gradientName
}
overrideGradientSchemeClick(pointclouds, setLastElevationGradient)

makeGlobeBackgroundOption()

initMeasurementsPanel(viewer)
initAnnotationsPanel(viewer)
})

const e = await Potree.loadPointCloud(pointcloudUrl)
const pc = e.pointcloud
viewer.scene.addPointCloud(pc)

// Change name of default background from 'None' to 'Globe"'
$('#background_options_none')
.text('Globe')
.attr('id', 'background_options_globe')
.val('globe')

viewer.setBackground('globe')

pc.material.pointSizeType = Potree.PointSizeType.ADAPTIVE
pc.material.shape = Potree.PointShape.CIRCLE
overrideShaderForGradient(pc)

//The default activeAttributeName is set to elevation and the color gradient to VIRIDIS for good visualization
pc.material.elevationRange = [-10000, 0]
pc.material.activeAttributeName = 'elevation'
pc.material.gradient = Potree.Gradients['VIRIDIS']

// Initialize camera position and target point (manually chosen)
viewer.scene.view.setView(
[3961574.044, 1494736.334, 8348318.575], // Initial camera position
Expand Down Expand Up @@ -259,3 +300,48 @@ function suppressSidebarAutoScroll(action, holdMs = 350) {
requestAnimationFrame(restoreLoop)
}
}

/**
* Overrides the click event handlers for gradient scheme selection to apply
* gradients to multiple point clouds.
*
* @param pointclouds - Array of point cloud objects
* @param {Function} setLastElevationGradient - Callback function to store the last selected gradient name
*/
function overrideGradientSchemeClick(pointclouds, setLastElevationGradient) {
const gradientContainer = document.getElementById(
'elevation_gradient_scheme_selection'
)
if (!gradientContainer) return
const spans = gradientContainer.querySelectorAll('span')
if (spans.length) {
spans.forEach((span, idx) => {
span.addEventListener('click', () => {
const gradientNames = Object.keys(Potree.Gradients)
const gradientName = gradientNames[idx]
if (gradientName) {
pointclouds.forEach((pc) => {
pc.material.gradient = Potree.Gradients[gradientName]
})
setLastElevationGradient(gradientName)
}
})
})
}
}

/**
* Converts the "None" background option to a "Globe" background option and sets it as the default.
*/
function makeGlobeBackgroundOption() {
const bgInput = document.getElementById('background_options_none')
const bgLabel = document.querySelector('label[for="background_options_none"]')

if (bgInput && bgLabel) {
bgLabel.textContent = 'Globe'
bgInput.id = 'background_options_globe'
bgInput.value = 'globe'
bgLabel.setAttribute('for', 'background_options_globe')
bgLabel.click()
}
}