diff --git a/README.md b/README.md index d48ceab..518effe 100644 --- a/README.md +++ b/README.md @@ -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) in `public/pointclouds/data_converted`. +Place the point cloud data (in Potree format with EPSG:4978 coordinates) in `public/pointclouds/data_converted`. **Note:** Point cloud files should not be committed to Git. diff --git a/index.html b/index.html index c77bfb1..9aee3f9 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,21 @@ type="text/css" href="/libs/jstree/themes/mixed/style.css" /> + + + + @@ -39,54 +54,30 @@ + +
-
+
+
+
+ + +
+
- + diff --git a/src/ElevationControl/elevationControl.js b/src/ElevationControl/elevationControl.js new file mode 100644 index 0000000..c4f28ae --- /dev/null +++ b/src/ElevationControl/elevationControl.js @@ -0,0 +1,113 @@ +//Cerating a customized section "Elevation Control" +window.createElevationPanel = function createElevationPanel(viewer) { + const container = document.getElementById('elevation_list') + let targetContainer = container + if (!targetContainer) { + // Create a new accordion section for Elevation Control + const menu = document.getElementById('potree_menu') + if (menu) { + const header = document.createElement('h3') + header.id = 'menu_elevation' + header.innerHTML = 'Elevation Control' + const panel = document.createElement('div') + panel.className = 'pv-menu-list' + panel.innerHTML = '
' + const about = document.getElementById('menu_appearance') + if (about) { + menu.insertBefore(panel, about) + menu.insertBefore(header, panel) + } else { + menu.appendChild(header) + menu.appendChild(panel) + } + // Activate accordion behavior if jQuery UI accordion already initialized + if ($(menu).accordion) { + try { + $(menu).accordion('refresh') + } catch (e) {} + } + // Toggle on header click if not managed by accordion refresh + header.addEventListener( + 'click', + () => + (panel.style.display = panel.style.display === 'none' ? '' : 'none') + ) + targetContainer = panel.querySelector('#elevation_list') + } + } +} + +//Select the fist pointcloud in the sidebar so that the Elevation section is built +function autoSelectFirstPointCloud() { + const cloudIcon = document.querySelector( + '#scene_objects i.jstree-themeicon-custom' + ) + if (cloudIcon) { + cloudIcon.dispatchEvent(new MouseEvent('click', { bubbles: true })) + return true + } + return false +} + +//(re)connect the elevation labels to the slider after the container is moved (was not handled by default) +function rebindElevationLabel() { + const slider = window.jQuery ? window.jQuery('#sldHeightRange') : null + const label = document.getElementById('lblHeightRange') + if (!slider || !slider.length || !label) return + + const update = () => { + const low = slider.slider('values', 0) + const high = slider.slider('values', 1) + label.textContent = `${low.toFixed(2)} to ${high.toFixed(2)}` + } + + // Adjust slider limits + slider.slider({ + min: -10000, + max: 0, + values: [-10000, 0] + }) + + //clear any old namespaced handlers and attach fresh ones + slider.off('slide.custom slidestop.custom change.custom') + slider.on('slide.custom', update) + slider.on('slidestop.custom change.custom', update) + update() +} + +//Move the elevation range section to the customised "Elevation Control" section +function moveElevationContainer() { + const target = document.getElementById('elevation_list') + const elevationContainer = document.querySelector( + '#materials\\.elevation_container' + ) + if (!elevationContainer) return false + target.appendChild(elevationContainer) + rebindElevationLabel() + return true +} + +//initiate and orchestrate all funcitons to render the Evelation control section of the sidebar propperly +export function initElevationControls(viewer) { + //Creates the section + createElevationPanel(viewer) + + //Only move the ElevationContainer if the source container to exist + const menu = + document.getElementById('potree_menu') || + document.getElementById('menu') || + document.body + const observer = new MutationObserver(() => { + const found = document.querySelector('#materials\\.elevation_container') + if (found) { + observer.disconnect() + //Move and rebind once it exists + const ok = moveElevationContainer() + if (!ok) console.warn('[Elevation] moveElevationContainer failed') + } + }) + observer.observe(menu, { childList: true, subtree: true }) + + //Trigger Potree to build Materials UI by selecting the first point cloud (if nothing selected yet) + autoSelectFirstPointCloud() +} diff --git a/src/MeasurementControl/measurementsPanel.css b/src/MeasurementControl/measurementsPanel.css new file mode 100644 index 0000000..96f735f --- /dev/null +++ b/src/MeasurementControl/measurementsPanel.css @@ -0,0 +1,273 @@ +#measurements_list .mcard { + background: #2d373c; + border: 1px solid #1c2428; + border-radius: 4px; + margin: 6px 4px; + padding: 6px 8px; + font-family: inherit; + font-size: 12px; + color: #eee; +} +#measurements_list .mheader { + display: flex; + align-items: center; + margin-bottom: 4px; + font-weight: bold; +} +#measurements_list .mstatus { + width: 8px; + height: 8px; + border-radius: 50%; + background: #68d96e; + margin-right: 6px; +} +#measurements_list .mdelete { + margin-left: auto; + cursor: pointer; + color: #d55; + font-weight: bold; + background: transparent; + border: none; + font-size: 14px; +} +#measurements_list table { + width: 100%; + border-collapse: collapse; + margin-top: 4px; +} +#measurements_list td { + padding: 2px 4px; +} +#measurements_list tr:nth-child(even) { + background: #3a454b; +} +#measurements_list .coordRow td { + background: #2f383d; + font-family: monospace; + font-size: 11px; + padding: 4px 6px; + border: 1px solid #404a50; + border-radius: 4px; + margin: 2px 0; +} +#measurements_list .segmentRow td { + background: transparent; + padding: 0 0 2px 0; +} +#measurements_list .segmentConnector { + width: 1px; + height: 12px; + background: #505b61; + margin: 2px auto 0; +} +#measurements_list .segmentPill { + display: inline-block; + background: #3d474d; + border: 1px solid #566067; + padding: 2px 10px; + margin: 2px auto 4px; + border-radius: 6px; + font-size: 11px; + font-family: monospace; +} +#measurements_list .totalRow td { + background: #424d53; + font-weight: 600; + font-size: 12px; + text-align: center; + border: 1px solid #59646a; + border-radius: 6px; + margin-top: 4px; +} +#measurements_list .separator td { + padding: 0; + height: 2px; + background: transparent; +} +#measurements_list .attrRow td { + font-size: 11px; + color: #cfd5d8; +} +#measurements_list .empty { + opacity: 0.6; + padding: 8px; + text-align: center; +} +#measurements_list { + max-height: 400px; + overflow-y: auto; + margin: 10px 0; +} +#measurements_list::-webkit-scrollbar { + width: 8px; +} +#measurements_list::-webkit-scrollbar-track { + background: var(--bg-dark-color); +} +#measurements_list::-webkit-scrollbar-thumb { + background: var(--bg-color-2); + border-radius: 4px; +} +#measurements_list::-webkit-scrollbar-thumb:hover { + background: var(--color-1); +} + +/* Modern grouped measurement list */ +#measurement_items { + background: #252d31; + border: 1px solid #1b2326; + border-radius: 6px; + font-size: 12px; + color: #d9e2e6; +} +#measurement_items .m-empty { + padding: 10px; + text-align: center; + opacity: 0.6; +} +#measurement_items .m-group { + border-top: 1px solid #303a3f; +} +#measurement_items .m-group:first-child { + border-top: none; +} +#measurement_items .m-group-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + font-size: 11px; + background: linear-gradient(#2f383d, #2b3438); + cursor: pointer; + user-select: none; + position: sticky; + top: 0; + z-index: 1; +} +#measurement_items .m-group-header:hover { + background: #374247; +} +#measurement_items .m-group-caret { + font-size: 10px; + width: 14px; + text-align: center; + color: #8fb9c9; +} +#measurement_items .m-group-title { + flex: 1; + color: #c7d4d9; +} +#measurement_items .m-group-count { + background: #3d4850; + color: #9fb7c2; + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + line-height: 1; +} +#measurement_items .m-group-body { + padding: 4px 4px 6px; +} +#measurement_items .m-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + margin: 2px 2px; + border: 1px solid transparent; + border-radius: 4px; + background: #2c3539; + transition: + background 0.15s, + border-color 0.15s; + cursor: pointer; +} +#measurement_items .m-row-icon { + width: 16px; + flex: 0 0 16px; + text-align: center; + font-size: 12px; + color: #8fb9c9; + filter: drop-shadow(0 0 2px #0a0f12); +} +#measurement_items .m-row:hover { + background: #354045; + border-color: #425056; +} +#measurement_items .m-row.active { + background: #1f4b63; + border-color: #2f6b8c; + box-shadow: 0 0 0 1px #2f6b8c66; +} +#measurement_items .m-row-label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +#measurement_items .m-row-delete { + background: #3b2626; + border: 1px solid #5a3a3a; + color: #ff9a9a; + font-weight: 600; + font-size: 11px; + line-height: 1; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} +#measurement_items .m-row-delete:hover { + background: #5a2d2d; + color: #fff; +} + +.measurements-panel { + position: relative; +} +.measurement-tools-host { + margin-bottom: 32px; +} +.measurement-items-root { + max-height: 260px; + overflow: auto; + margin-bottom: 6px; + border: 1px solid #333; + border-radius: 4px; +} +.measurement-info-message { + padding: 4px 8px; + font-size: 12px; + opacity: 0.6; +} + +.m-list-title, +.m-data-title { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + padding: 6px 8px 4px 14px; + margin-left: 2px; + color: #c7d4d9; + display: flex; + align-items: center; + gap: 6px; +} +.measurement-options-block { + margin-top: 12px !important; + display: block; +} +.m-list-title { + margin: 6px 0 4px; +} +.m-data-title { + margin: 10px 0 8px; + border-top: 1px solid #303a3f; + padding-top: 10px; +} diff --git a/src/MeasurementControl/measurementsPanel.js b/src/MeasurementControl/measurementsPanel.js new file mode 100644 index 0000000..6b70ac2 --- /dev/null +++ b/src/MeasurementControl/measurementsPanel.js @@ -0,0 +1,678 @@ +/** + * Measurements Panel + * Injects a custom Measurements tab section, shows grouped measurement + * entries with per-type numbering, syncs selection with Potree's jsTree, and + * dynamically mounts the native properties panel when a measurement is active. + */ +export function initMeasurementsPanel(viewer) { + // Track last selected measurement label for dynamic data title + const lastSelection = { uuid: null, label: '' } + // Resolve or create measurements container in Potree menu + const existingListContainer = document.getElementById('measurements_list') + let targetContainer = existingListContainer + if (!targetContainer) { + const menu = document.getElementById('potree_menu') + if (menu) { + const header = document.createElement('h3') + header.id = 'menu_point_measurements' + const headerSpan = document.createElement('span') + headerSpan.textContent = 'Measurements' + header.appendChild(headerSpan) + const panel = document.createElement('div') + panel.className = 'pv-menu-list measurements-panel' + const toolsHostDiv = document.createElement('div') + toolsHostDiv.id = 'measurement_tools_host' + toolsHostDiv.className = 'measurement-tools-host' + const listContainerDiv = document.createElement('div') + listContainerDiv.id = 'measurements_list' + listContainerDiv.className = 'auto' + panel.appendChild(toolsHostDiv) + panel.appendChild(listContainerDiv) + // Insert before filters/tools if possible, else append at end + const tools = document.getElementById('menu_tools') + if (tools) { + menu.insertBefore(panel, tools) + menu.insertBefore(header, panel) + } else { + menu.appendChild(header) + menu.appendChild(panel) + } + // Activate tab behavior if jQuery UI accordion already initialized + if ($(menu).accordion) { + try { + $(menu).accordion('refresh') + } catch (e) {} + } + header.addEventListener('click', () => { + if ($(menu).accordion && $(menu).data('uiAccordion')) return + if (window.jQuery) { + const $p = window.jQuery(panel) + $p.is(':visible') ? $p.slideUp(350) : $p.slideDown(350) + return + } + }) + targetContainer = panel.querySelector('#measurements_list') + } + } + if (!targetContainer) { + console.warn( + 'Measurements list container not found and dynamic injection failed' + ) + return + } + + let listRoot = document.createElement('div') + listRoot.id = 'measurement_items' + listRoot.className = 'measurement-items-root' + const listDivider = document.createElement('div') + listDivider.className = 'divider' + const dividerSpan = document.createElement('span') + dividerSpan.textContent = 'List of Measurements' + listDivider.appendChild(dividerSpan) + if (targetContainer.parentElement) { + targetContainer.parentElement.insertBefore(listDivider, targetContainer) + targetContainer.parentElement.insertBefore(listRoot, targetContainer) + } + + // Creating an incremental number for each measurment type + const creationOrder = [] + const uuidRef = new Map() + + function trackCreation(obj) { + if (!obj || !obj.uuid) return + if (!uuidRef.has(obj.uuid)) { + uuidRef.set(obj.uuid, obj) + creationOrder.push(obj.uuid) + } else { + uuidRef.set(obj.uuid, obj) + } + } + + function buildTypeIndices(items) { + const indexMap = new Map() + for (const uuid of creationOrder) { + const entry = items.find((it) => it.obj.uuid === uuid) + if (!entry) continue + const t = entry.type + if (!indexMap.has(t)) indexMap.set(t, new Map()) + const map = indexMap.get(t) + if (!map.has(uuid)) map.set(uuid, map.size + 1) + } + return indexMap + } + + const TYPE_ICONS = { + Point: '●', + Distance: '﹔', + Height: '↕', + Area: '▧', + Angle: '∠', + Circle: '◯', + Azimuth: 'N', + Volume: '▣', + Profile: '≋', + Measurement: '●' + } + + function resolveType(m) { + const ctor = m.constructor?.name || '' + if (/Volume/i.test(ctor)) return 'Volume' + if (/Profile/i.test(ctor)) return 'Profile' + if (/Circle/i.test(ctor) || m.showCircle) return 'Circle' + if (/Angle/i.test(ctor) || m.showAngle) return 'Angle' + if (/Height/i.test(ctor) || (m.showHeight && m.points?.length >= 2)) + return 'Height' + if (/Azimuth/i.test(ctor) || m.showAzimuth) return 'Azimuth' + if (/Area/i.test(ctor) || (m.showDistances && m.showArea)) return 'Area' + if (m.className) return m.className + // Fallback heuristics on point count + if (m.points) { + if (m.points.length === 1) return 'Point' + if (m.points.length === 2 && m.showHeight) return 'Height' + return 'Distance' + } + return 'Measurement' + } + + const originalPropertiesPanel = document.querySelector( + '#scene_object_properties' + ) + // This section is for moving the "scene" object to the meaasurements tab, only if the measurements section is selected, so that all other folders (like camera) stays where it should be + let placeholder = null + let originalParent = null + function ensurePlaceholder() { + if (originalPropertiesPanel && !placeholder) { + originalParent = originalPropertiesPanel.parentElement + placeholder = document.createElement('div') + placeholder.id = 'scene_object_properties_placeholder' + placeholder.style.display = 'none' + originalParent.insertBefore(placeholder, originalPropertiesPanel) + } + } + function showPanelInMeasurements() { + ensurePlaceholder() + if (!originalPropertiesPanel) return + const hasSelection = !!lastSelection.label + if (originalPropertiesPanel.parentElement !== targetContainer) { + targetContainer.innerHTML = '' + if (hasSelection) { + const titleEl = document.createElement('div') + titleEl.id = 'measurement_data_title' + titleEl.className = 'm-data-title' + titleEl.textContent = `Data for ${lastSelection.label}` + targetContainer.appendChild(titleEl) + } else { + const msg = document.createElement('div') + msg.className = 'measurement-info-message' + msg.textContent = 'Select a measurement to view its properties here' + targetContainer.appendChild(msg) + } + targetContainer.appendChild(originalPropertiesPanel) + } else { + const existingTitle = targetContainer.querySelector( + '#measurement_data_title' + ) + const existingInfo = targetContainer.querySelector( + '.measurement-info-message' + ) + if (hasSelection) { + if (!existingTitle) { + if (existingInfo) existingInfo.remove() + const titleEl = document.createElement('div') + titleEl.id = 'measurement_data_title' + titleEl.className = 'm-data-title' + titleEl.textContent = `Data for ${lastSelection.label}` + targetContainer.insertBefore(titleEl, originalPropertiesPanel) + } else { + existingTitle.textContent = `Data for ${lastSelection.label}` + } + } else { + if (existingTitle) existingTitle.remove() + if (!existingInfo) { + const msg = document.createElement('div') + msg.className = 'measurement-info-message' + msg.textContent = 'Select a measurement to view its properties here' + targetContainer.insertBefore(msg, originalPropertiesPanel) + } + } + } + requestAnimationFrame(() => { + roundCoordinates(originalPropertiesPanel) + initCoordObserver() + }) + } + function restorePanelToOriginal() { + if (!originalPropertiesPanel || !placeholder || !originalParent) return + if (originalPropertiesPanel.parentElement !== originalParent) { + originalParent.insertBefore( + originalPropertiesPanel, + placeholder.nextSibling + ) + } + if (targetContainer && targetContainer.children.length === 0) { + targetContainer.innerHTML = '' + const msg = document.createElement('div') + msg.className = 'measurement-info-message' + msg.textContent = 'Select a measurement to view its properties here' + targetContainer.appendChild(msg) + } + } + + if (targetContainer) { + targetContainer.innerHTML = '' + const msg2 = document.createElement('div') + msg2.className = 'measurement-info-message' + msg2.textContent = 'Select a measurement to view its properties here' + targetContainer.appendChild(msg2) + } + + // Helper: round only the coordinates table (x,y,z header row) to 1 decimal + let coordRoundObserver = null + function initCoordObserver() { + if (!originalPropertiesPanel || coordRoundObserver) return + coordRoundObserver = new MutationObserver(() => { + requestAnimationFrame(() => roundCoordinates(originalPropertiesPanel)) + }) + coordRoundObserver.observe(originalPropertiesPanel, { + childList: true, + subtree: true + }) + } + + function roundCoordinates(rootEl) { + if (!rootEl) return + // Find first table that has a header row with th: x y z + const tables = rootEl.querySelectorAll('table.measurement_value_table') + let coordTable = null + for (const tbl of tables) { + const headerRow = tbl.querySelector('tr') + if (!headerRow) continue + const ths = [...headerRow.querySelectorAll('th')].map((th) => + (th.textContent || '').trim().toLowerCase() + ) + if ( + ths.length >= 3 && + ths[0] === 'x' && + ths[1] === 'y' && + ths[2] === 'z' + ) { + coordTable = tbl + break + } + } + if (!coordTable) return + + const dataRows = [...coordTable.querySelectorAll('tr')].slice(1) + dataRows.forEach((row) => { + row.querySelectorAll('td').forEach((td) => { + if (td.querySelector('button, input, select')) return + const raw = (td.textContent || '').trim() + if (!raw) return + // Allow formats with commas, spaces, possible degree sign, trailing labels + // Extract leading numeric with optional sign & decimal + const cleaned = raw + .replace(/[^\d+.\-]/g, (c) => (c === ',' ? '' : '')) // clean data + .replace(/,+/g, '') + if (!cleaned || !/[-+]?\d*\.?\d+/.test(cleaned)) return + const num = Number(cleaned) + if (!Number.isFinite(num)) return + const rounded = num.toFixed(1) + if (td.textContent !== rounded) { + td.textContent = rounded + } + }) + }) + } + // Helper to decide if a uuid is a measurement-like object + function isMeasurementUUID(uuid) { + if (!uuid) return false + const s = viewer.scene + return ( + s.measurements.some((m) => m.uuid === uuid) || + s.volumes.some((v) => v.uuid === uuid) || + s.profiles.some((p) => p.uuid === uuid) + ) + } + + // Update visual active state in list and track lastSelection label + function updateActiveSelection(uuid) { + ;[...listRoot.querySelectorAll('.m-row[data-uuid]')].forEach((el) => { + el.classList.toggle('active', el.dataset.uuid === uuid) + }) + if (uuid) { + const labelEl = listRoot.querySelector( + `.m-row[data-uuid="${uuid}"] .m-row-label` + ) + if (labelEl) { + lastSelection.uuid = uuid + lastSelection.label = labelEl.textContent.trim() + } + } else { + lastSelection.uuid = null + lastSelection.label = '' + } + } + + // If on load there's already a selected measurement, move panel immediately + setTimeout(() => { + try { + const tree = $('#jstree_scene').jstree && $('#jstree_scene').jstree() + if (tree) { + const sel = tree.get_selected(true)[0] + if (sel && sel.data && isMeasurementUUID(sel.data.uuid)) { + showPanelInMeasurements() + } + } + } catch (_e) {} + }, 0) + + // Build/update the measurement list + function rebuildMeasurementList() { + if (!listRoot) return + const scene = viewer.scene + const itemsRaw = [ + ...scene.measurements.map((m) => ({ type: resolveType(m), obj: m })), + ...scene.profiles.map((p) => ({ type: 'Profile', obj: p })), + ...scene.volumes.map((v) => ({ type: 'Volume', obj: v })) + ] + itemsRaw.forEach((entry) => trackCreation(entry.obj)) + const perTypeNumbers = buildTypeIndices(itemsRaw) + + // Group by type while preserving overall creation order within each type + const groups = new Map() + for (const entry of itemsRaw) { + if (!groups.has(entry.type)) groups.set(entry.type, []) + groups.get(entry.type).push(entry) + } + const order = Array.from(groups.keys()).sort() + listRoot.innerHTML = '' + if (itemsRaw.length === 0) { + const emptyEl = document.createElement('div') + emptyEl.className = 'm-empty' + emptyEl.textContent = 'No measurements' + listRoot.appendChild(emptyEl) + return + } + order.forEach((type) => { + const section = document.createElement('div') + section.className = 'm-group' + const gid = 'g_' + type.toLowerCase() + const header = document.createElement('div') + header.className = 'm-group-header' + header.dataset.group = gid + header.dataset.open = 'true' + const caret = document.createElement('span') + caret.className = 'm-group-caret' + caret.textContent = '▾' + const titleSpan = document.createElement('span') + titleSpan.className = 'm-group-title' + titleSpan.textContent = type + const countSpan = document.createElement('span') + countSpan.className = 'm-group-count' + countSpan.id = gid + '_count' + header.appendChild(caret) + header.appendChild(titleSpan) + header.appendChild(countSpan) + const body = document.createElement('div') + body.className = 'm-group-body' + body.id = gid + '_body' + section.appendChild(header) + section.appendChild(body) + listRoot.appendChild(section) + groups.get(type).forEach((entry) => { + const m = entry.obj + const num = perTypeNumbers.get(type)?.get(m.uuid) || 0 + const baseName = `${type} #${num}` + const row = document.createElement('div') + row.className = 'm-row' + row.dataset.uuid = m.uuid + const iconSpan = document.createElement('span') + iconSpan.className = 'm-row-icon' + iconSpan.textContent = TYPE_ICONS[type] || TYPE_ICONS['Measurement'] + const labelSpan = document.createElement('span') + labelSpan.className = 'm-row-label' + labelSpan.title = baseName + labelSpan.textContent = baseName + const delBtn = document.createElement('button') + delBtn.className = 'm-row-delete' + delBtn.dataset.act = 'delete' + delBtn.title = 'Delete' + delBtn.textContent = '×' + row.appendChild(iconSpan) + row.appendChild(labelSpan) + row.appendChild(delBtn) + body.appendChild(row) + }) + countSpan.textContent = groups.get(type).length + }) + if (originalPropertiesPanel.parentElement === targetContainer) { + let selectedUUID = null + try { + const tree = $('#jstree_scene').jstree && $('#jstree_scene').jstree() + if (tree) { + const sel = tree.get_selected(true)[0] + sel && + sel.data && + isMeasurementUUID(sel.data.uuid) && + (selectedUUID = sel.data.uuid) + } + } catch (_e) {} + if ( + selectedUUID && + listRoot.querySelector(`.m-row[data-uuid="${selectedUUID}"]`) + ) { + updateActiveSelection(selectedUUID) + lastSelection.label && showPanelInMeasurements() + } else { + lastSelection.uuid = null + lastSelection.label = '' + showPanelInMeasurements() + } + } + } + + rebuildMeasurementList() + + // Hook into scene add/remove events to refresh list + function handleMeasurementLikeAdded(obj) { + if (!obj) return + // Attach point/marker listeners for any measurement-like object with points + if (obj.addEventListener && !obj._mp_listenersAttached) { + obj._mp_listenersAttached = true + ;[ + 'marker_added', + 'marker_removed', + 'point_added', + 'point_removed' + ].forEach((ev) => { + try { + obj.addEventListener(ev, () => { + rebuildMeasurementList() + if (lastSelection.uuid === obj.uuid) { + updateActiveSelection(obj.uuid) + showPanelInMeasurements() + } + }) + } catch (_e) {} + }) + } + + // Distance/Height/Angle measurements can change type based on point count + if (!obj._mp_typeWatcher && obj.points) { + obj._mp_typeWatcher = true + let lastType = resolveType(obj) + const watcher = setInterval(() => { + const currentType = resolveType(obj) + if (currentType !== lastType) { + lastType = currentType + rebuildMeasurementList() + if (lastSelection.uuid === obj.uuid) { + updateActiveSelection(obj.uuid) + showPanelInMeasurements() + } + } + if (currentType !== 'Point' || (obj.points && obj.points.length > 1)) { + clearInterval(watcher) + } + }, 250) + } + rebuildMeasurementList() + if (!isMeasurementUUID(obj.uuid)) return + let autoSelect = false + try { + const tree = $('#jstree_scene').jstree && $('#jstree_scene').jstree() + if (tree) { + const sel = tree.get_selected(true)[0] + if (!sel || !sel.data || !isMeasurementUUID(sel.data.uuid)) + autoSelect = true + } + } catch (_e) {} + if (autoSelect) { + try { + const treeEl = document.getElementById('jstree_scene') + if (treeEl && $(treeEl).jstree) { + const tree = $(treeEl).jstree() + const measurementsRoot = tree.get_json('measurements') + const findNode = (root) => + root.children.find((ch) => ch.data && ch.data.uuid === obj.uuid) + const node = measurementsRoot && findNode(measurementsRoot) + if (node) { + $.jstree.reference(node.id).deselect_all() + $.jstree.reference(node.id).select_node(node.id) + updateActiveSelection(obj.uuid) + } + } + } catch (_e) {} + } else { + updateActiveSelection(obj.uuid) + } + showPanelInMeasurements() + } + + viewer.scene.addEventListener('measurement_added', (e) => { + const obj = e.measurement || e.object || e.detail || null + handleMeasurementLikeAdded(obj) + }) + viewer.scene.addEventListener('volume_added', (e) => { + const obj = e.volume || e.object || e.detail || null + handleMeasurementLikeAdded(obj) + }) + viewer.scene.addEventListener('profile_added', (e) => { + const obj = e.profile || e.object || e.detail || null + handleMeasurementLikeAdded(obj) + }) + + viewer.scene.addEventListener('measurement_removed', (e) => { + rebuildMeasurementList() + const removed = e.measurement || e.object || e.detail || null + if (removed && removed.uuid && removed.uuid === lastSelection.uuid) { + lastSelection.uuid = null + lastSelection.label = '' + showPanelInMeasurements() + } + }) + viewer.scene.addEventListener('volume_removed', (e) => { + rebuildMeasurementList() + const removed = e.volume || e.object || e.detail || null + if (removed && removed.uuid && removed.uuid === lastSelection.uuid) { + lastSelection.uuid = null + lastSelection.label = '' + showPanelInMeasurements() + } + }) + viewer.scene.addEventListener('profile_removed', (e) => { + rebuildMeasurementList() + const removed = e.profile || e.object || e.detail || null + if (removed && removed.uuid && removed.uuid === lastSelection.uuid) { + lastSelection.uuid = null + lastSelection.label = '' + showPanelInMeasurements() + } + }) + + // Click handling for selection, focus and delete + listRoot.addEventListener('click', (e) => { + const header = e.target.closest('.m-group-header') + if (header) { + const open = header.getAttribute('data-open') === 'true' + header.setAttribute('data-open', String(!open)) + const caret = header.querySelector('.m-group-caret') + const body = header.parentElement.querySelector('.m-group-body') + if (body) { + body.style.display = open ? 'none' : 'block' + } + if (caret) { + caret.textContent = open ? '▸' : '▾' + } + return + } + const btn = e.target.closest('button') + const row = e.target.closest('.m-row[data-uuid]') + if (!row) return + const uuid = row.dataset.uuid + const scene = viewer.scene + const all = [...scene.measurements, ...scene.profiles, ...scene.volumes] + const obj = all.find((o) => o.uuid === uuid) + if (!obj) return + if (btn) { + const act = btn.dataset.act + if (act === 'delete') { + if (scene.removeMeasurement && scene.measurements.includes(obj)) + scene.removeMeasurement(obj) + else if (scene.removeVolume && scene.volumes.includes(obj)) + scene.removeVolume(obj) + else if (scene.removeProfile && scene.profiles.includes(obj)) + scene.removeProfile(obj) + rebuildMeasurementList() + return + } + } else { + const treeElement = document.getElementById('jstree_scene') + if (treeElement && $(treeElement).jstree) { + let measurementsRoot = $('#jstree_scene') + .jstree() + .get_json('measurements') + const findNode = (root) => + root.children.find((ch) => ch.data && ch.data.uuid === uuid) + let node = measurementsRoot && findNode(measurementsRoot) + if (!node) { + node = + measurementsRoot && + measurementsRoot.children.find( + (ch) => ch.data && ch.data.uuid === uuid + ) + } + if (node) { + $.jstree.reference(node.id).deselect_all() + $.jstree.reference(node.id).select_node(node.id) + } + } + updateActiveSelection(uuid) + showPanelInMeasurements() + } + }) + + // Sync highlight if selection changes via original tree + document.addEventListener('click', (e) => { + if (!e.target.closest('#jstree_scene')) return + setTimeout(() => { + const tree = $('#jstree_scene').jstree() + if (!tree) return + const sel = tree.get_selected(true)[0] + if (sel && sel.data && sel.data.uuid) { + const uuid = sel.data.uuid + updateActiveSelection(uuid) + // Determine if selected node is a measurement-like object; if not, restore. + const isMeasurement = + sel && sel.data && isMeasurementUUID(sel.data.uuid) + if (isMeasurement) { + showPanelInMeasurements() + } else { + restorePanelToOriginal() + } + } + }, 0) + }) + + // Move existing tools UI into this section + const toolsHost = document.getElementById('measurement_tools_host') + const existingTools = document.getElementById('tools') + if (toolsHost && existingTools) { + const parentPanel = toolsHost.parentElement + if (parentPanel) { + const toolsDivider = document.createElement('div') + toolsDivider.className = 'divider pv-tools-divider' + const span = document.createElement('span') + span.textContent = 'Tools' + toolsDivider.appendChild(span) + parentPanel.insertBefore(toolsDivider, toolsHost) + } + toolsHost.appendChild(existingTools) + } + + // Move measurement options UI into our tools host + if (toolsHost) { + const measOptions = document.getElementById('measurement_options_show') + if (measOptions) { + const measLi = measOptions.closest('li') || measOptions + if (measLi && !measLi.classList.contains('measurement-options-block')) { + measLi.classList.add('measurement-options-block') + } + let prev = measLi.previousElementSibling + for (let i = 0; i < 3 && prev; i++) { + if ( + prev.classList && + prev.classList.contains('divider') && + /Measurement/i.test(prev.textContent || '') + ) { + prev.remove() + break + } + prev = prev.previousElementSibling + } + if (measLi && measLi.parentElement !== toolsHost) { + toolsHost.appendChild(measLi) + } + } + } +} diff --git a/src/cameraSync.js b/src/cameraSync.js new file mode 100644 index 0000000..35fcd5e --- /dev/null +++ b/src/cameraSync.js @@ -0,0 +1,101 @@ +/** + * Syncs Potree's point cloud with Cesium's globe. + * + * @param potreeViewer - used for point cloud + * @param cesiumViewer - used for globe + */ +export function syncCameras(potreeViewer, cesiumViewer) { + const camera = potreeViewer.scene.getActiveCamera() + + // Compute camera position, up vector, and target (pivot) in world coordinates + const pPos = new THREE.Vector3(0, 0, 0).applyMatrix4(camera.matrixWorld) + const pUp = new THREE.Vector3(0, 600, 0).applyMatrix4(camera.matrixWorld) + const pTarget = potreeViewer.scene.view.getPivot() + + const toCes = (v) => new Cesium.Cartesian3(v.x, v.y, v.z) + + const cPos = toCes(pPos) + const cUpTarget = toCes(pUp) + const cTarget = toCes(pTarget) + + // Compute Cesium camera direction and up vectors + const cDir = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(cTarget, cPos, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ) + const cUp = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(cUpTarget, cPos, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ) + + // Hide globe when the camera is below the surface, blocked by the curvature of the Earth or directly above the pivot + const showGlobe = shouldShowGlobe(cPos, cTarget, cDir) + cesiumViewer.scene.globe.show = showGlobe + cesiumViewer.scene.skyAtmosphere.show = showGlobe + + // Sync Cesium camera position and orientation with Potree + cesiumViewer.camera.setView({ + destination: cPos, + orientation: { + direction: cDir, + up: cUp + } + }) + + // Match FOV + const aspect = camera.aspect + const fovy = Math.PI * (camera.fov / 180) + if (aspect < 1) { + cesiumViewer.camera.frustum.fov = fovy + } else { + const fovx = Math.atan(Math.tan(0.5 * fovy) * aspect) * 2 + cesiumViewer.camera.frustum.fov = fovx + } +} + +/** + * Determines whether the globe should be visible based on the camera position. + * + * Returns false if the camera is below the globe surface, if the pivot + * point would be blocked by the curvature of the Earth or if the camera + * is looking almost straight down at the pivot. + * + * @param cameraPos - The camera position in Cesium.Cartesian3 coordinates + * @param pivot - The pivot point in Cesium.Cartesian3 coordinates + * @param direction - The camera direction as a Cesium.Cartesian3 unit vector + * @returns true if the globe should be visible, false if it should be hidden + */ +function shouldShowGlobe(cameraPos, pivot, direction) { + const ellipsoid = Cesium.Ellipsoid.WGS84 + const earthCenter = Cesium.Cartesian3.ZERO + + // Get point on globe surface directly above the pivot + const carto = Cesium.Cartographic.fromCartesian(pivot) + const pivotSurface = Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + 0, + ellipsoid + ) + + // Axis vector from Earth center through pivot + const axis = Cesium.Cartesian3.subtract( + pivotSurface, + earthCenter, + new Cesium.Cartesian3() + ) + Cesium.Cartesian3.normalize(axis, axis) + + // Project camera and pivot onto this axis + const camProj = Cesium.Cartesian3.dot(cameraPos, axis) + const pivotProj = Cesium.Cartesian3.dot(pivotSurface, axis) + + // Compute the dot product between camera direction and local vertical + // Used to detect if the camera is looking almost straight down + const targetNormal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(pivot) + const dotProduct = Math.abs(Cesium.Cartesian3.dot(direction, targetNormal)) + + // If camera is "above" pivot on the axis, and not looking nearly straight down, the globe should be visible + // Otherwise, the globe should not be visible + return camProj >= pivotProj && dotProduct < 0.99 +} diff --git a/src/cesiumViewer.js b/src/cesiumViewer.js new file mode 100644 index 0000000..af8336e --- /dev/null +++ b/src/cesiumViewer.js @@ -0,0 +1,26 @@ +/** + * Initializes the Cesium viewer used to visualize the globe. + * + * @param containerId - id of the container + * @returns Cesium viewer + */ +export function createCesiumViewer(containerId) { + const viewer = new Cesium.Viewer(containerId, { + useDefaultRenderLoop: false, + animation: false, + baseLayerPicker: false, + fullscreenButton: false, + geocoder: false, + homeButton: false, + infoBox: false, + sceneModePicker: false, + selectionIndicator: false, + timeline: false, + navigationHelpButton: false, + imageryProvider: Cesium.createOpenStreetMapImageryProvider({ + url: 'https://a.tile.openstreetmap.org/' + }), + terrainShadows: Cesium.ShadowMode.DISABLED + }) + return viewer +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..09c17aa --- /dev/null +++ b/src/config.js @@ -0,0 +1,10 @@ +export const POTREE_POINTCLOUD_URL = '/pointclouds/data_converted/metadata.json' + +export const POTREE_SETTINGS = { + edl: true, + fov: 60, + pointBudget: 1_000_000 +} + +export const ecef = '+proj=geocent +datum=WGS84 +units=m +no_defs' // EPSG:4978 (geocentric coordinates) +export const wgs84 = '+proj=longlat +datum=WGS84 +no_defs' // EPSG:4326 (geographic coordinates) diff --git a/src/coordinateShowing/coordinateShowing.css b/src/coordinateShowing/coordinateShowing.css new file mode 100644 index 0000000..0a8ac75 --- /dev/null +++ b/src/coordinateShowing/coordinateShowing.css @@ -0,0 +1,26 @@ +#canvasContainer { + display: flex; + flex-direction: column; + position: absolute; + right: 10px; + bottom: 10px; +} + +#posCanvas { + position: relative; + width: 300px; + height: 50px; + background-color: #19282c; + z-index: 10; + border-radius: 5px; +} + +#elevationCanvas { + position: relative; + width: 300px; + height: 50px; + background-color: #19282c; + z-index: 10; + margin-bottom: 10px; + border-radius: 5px; +} diff --git a/src/coordinateShowing/coordinateShowing.js b/src/coordinateShowing/coordinateShowing.js new file mode 100644 index 0000000..8045cd0 --- /dev/null +++ b/src/coordinateShowing/coordinateShowing.js @@ -0,0 +1,80 @@ +import { ecef } from '../config.js' +import { wgs84 } from '../config.js' + +const posCanvas = document.getElementById('posCanvas') // lat/lon +const elevationCanvas = document.getElementById('elevationCanvas') + +export let posCtx +export let elevationCtx + +/** + * Initializes the canvases and their contexts. + */ +export function initCoordinateCanvases() { + posCtx = resizeCanvas(posCanvas) + elevationCtx = resizeCanvas(elevationCanvas) +} + +/** + * Resizes the canvas and its context to account for device pixel ratio. + * @param {*} canvas - The canvas element to resize. + * @returns {*} - The resized canvas context. + */ +function resizeCanvas(canvas) { + const dpr = window.devicePixelRatio || 1 + const ctx = canvas.getContext('2d') + + canvas.width = canvas.clientWidth * dpr + canvas.height = canvas.clientHeight * dpr + + // Scale context so drawing uses CSS pixels + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + return ctx +} + +/** + * Draw the text on a given canvas. + */ +function drawText(ctx, text, canvas) { + const centerX = canvas.clientWidth / 2 + const centerY = canvas.clientHeight / 2 + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = '#cccccc' + ctx.font = '20px Arial' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(text, centerX, centerY) +} + +/** + * Updates the lat/lon coordinates. + */ +export function updateCoordinateText() { + const cam = window.potreeViewer.scene.view.position + const [lon, lat] = proj4(ecef, wgs84, [cam.x, cam.y, cam.z]) + drawText( + posCtx, + `lat = ${lat.toFixed(5)}˚ lon = ${lon.toFixed(5)}˚`, + posCanvas + ) +} + +/** + * Shows target elevations if camera is in orbit mode. + */ +export function updateTargetElevation() { + const pivot = window.potreeViewer.scene.view.getPivot() + const controls = window.potreeViewer.getControls() + const height = proj4(ecef, wgs84, [pivot.x, pivot.y, pivot.z])[2] + + if (controls === window.potreeViewer.orbitControls) { + elevationCanvas.style.display = 'inline' + drawText( + elevationCtx, + `Target elevation = ${height.toFixed(4)}m`, + elevationCanvas + ) + } else { + elevationCanvas.style.display = 'none' + } +} diff --git a/src/main.js b/src/main.js index 79eee5f..aaa415a 100644 --- a/src/main.js +++ b/src/main.js @@ -1 +1,38 @@ -/* Empty for now, add logic later */ +import { POTREE_POINTCLOUD_URL, POTREE_SETTINGS } from './config.js' +import { createCesiumViewer } from './cesiumViewer.js' +import { createPotreeViewer } from './potreeViewer.js' +import { syncCameras } from './cameraSync.js' +import { + initCoordinateCanvases, + updateCoordinateText, + updateTargetElevation +} from './CoordinateShowing/coordinateShowing.js' + +async function init() { + window.cesiumViewer = createCesiumViewer('cesiumContainer') + + window.potreeViewer = await createPotreeViewer( + 'potree_render_area', + POTREE_POINTCLOUD_URL, + POTREE_SETTINGS + ) + + initCoordinateCanvases() + + potreeViewer.addEventListener('update', updateCoordinateText) + potreeViewer.addEventListener('update', updateTargetElevation) + + window.addEventListener('resize', initCoordinateCanvases) + + function loop(timestamp) { + requestAnimationFrame(loop) + potreeViewer.update(potreeViewer.clock.getDelta(), timestamp) + potreeViewer.render() + syncCameras(potreeViewer, cesiumViewer) + cesiumViewer.render() + } + + requestAnimationFrame(loop) +} + +init() diff --git a/src/potreeViewer.js b/src/potreeViewer.js new file mode 100644 index 0000000..fb709f0 --- /dev/null +++ b/src/potreeViewer.js @@ -0,0 +1,136 @@ +import { initElevationControls } from './ElevationControl/elevationControl.js' +import { initMeasurementsPanel } from './MeasurementControl/measurementsPanel.js' +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 settings - other settings + * @returns Potree viewer + */ +export async function createPotreeViewer(containerId, pointcloudUrl, settings) { + const viewer = new Potree.Viewer(document.getElementById(containerId), { + useDefaultRenderLoop: false + }) + + // Remove original scroll listener and add new one + const oc = viewer.orbitControls + oc.removeEventListener('mousewheel', oc._listeners?.mousewheel?.[0]) + oc.addEventListener('mousewheel', clampScrollRadius) + + if (settings.edl) viewer.setEDLEnabled(true) + if (settings.fov) viewer.setFOV(settings.fov) + if (settings.pointBudget) viewer.setPointBudget(settings.pointBudget) + + viewer.loadSettingsFromURL() + viewer.setDescription('Molloy Explorer') + + viewer.loadGUI(() => { + viewer.setLanguage('en') + $('#menu_appearance').next().show() + $('#menu_tools').next().show() + $('#menu_scene').next().show() + $('#menu_filters').next().show() + viewer.toggleSidebar() + + initElevationControls(viewer) + initMeasurementsPanel(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) + pc.material.elevationRange = [-10000, 0] + pc.material.activeAttributeName = 'elevation' + pc.material.gradient = Potree.Gradients['VIRIDIS'] + + e.pointcloud.projection = ecef + + // Initialize camera position and target point (manually chosen) + viewer.scene.view.setView( + [1993552.9, 87954.487, 7134018.721], // Initial camera position + [1184471.63, 63828.49, 6243615.52] // Initial target point + ) + + return viewer +} + +/** + * Replacement for original scroll function which limits how far you can zoom out. + * + * @param e - the given event + */ +function clampScrollRadius(e) { + let resolvedRadius = this.scene.view.radius + this.radiusDelta + let newRadius = resolvedRadius - e.delta * resolvedRadius * 0.1 + + const maxRadius = 10000000 + if (newRadius > maxRadius) newRadius = maxRadius + + this.radiusDelta = newRadius - this.scene.view.radius + this.stopTweens() +} + +/** + * Adjust shader to use elevation relative to sealevel for EPSG:4978 coordinates. + * + * @param pc - the point cloud + */ +function overrideShaderForGradient(pc) { + const originalUpdateShaderSource = pc.material.updateShaderSource + pc.material.updateShaderSource = function () { + // Call the original updateShaderSource first + originalUpdateShaderSource.call(this) + + // Override the shader's getElevation function to use elevation relative to sealevel + this.vertexShader = this.vertexShader.replace( + /vec3 getElevation\(\)[\s\S]*?\}/, + ` + vec3 getElevation(){ + // Transform the vertex position into world coordinates + vec4 world = modelMatrix * vec4(position, 1.0); + + // Compute distance from Earth's center and latitude + float radius = length(world.xyz); + float latitude = asin(world.z / radius); + + const float a = 6378137.0; // Equatorial radius + const float b = 6356752.3; // Polar radius + + // Compute distance from Earth's center to the surface at the given latitude + float cosLat = cos(latitude); + float sinLat = sin(latitude); + float numerator = (a*a * cosLat) * (a*a * cosLat) + (b*b * sinLat) * (b*b * sinLat); + float denominator = (a * cosLat) * (a * cosLat) + (b * sinLat) * (b * sinLat); + float radiusAtLatitude = sqrt(numerator / denominator); + + // Compute depth below the ellipsoid (sea level) + float depth = radius - radiusAtLatitude; + + // Normalize depth to a [0, 1] range for coloring + float w = (depth - elevationRange.x) / (elevationRange.y - elevationRange.x); + + // Sample color from gradient texture based on normalized depth + return texture2D(gradient, vec2(w, 1.0-w)).rgb; + } + ` + ) + + // Mark the material as needing recompilation + this.needsUpdate = true + } +} diff --git a/src/style.css b/src/style.css index 5bc7676..e69de29 100644 --- a/src/style.css +++ b/src/style.css @@ -1 +0,0 @@ -/* Empty for now, add styles later */