diff --git a/index.html b/index.html index 2c4e868..b0488b6 100644 --- a/index.html +++ b/index.html @@ -40,6 +40,7 @@ /> + diff --git a/src/2DProfileOverride/2DProfileOverride.css b/src/2DProfileOverride/2DProfileOverride.css new file mode 100644 index 0000000..b14df6d --- /dev/null +++ b/src/2DProfileOverride/2DProfileOverride.css @@ -0,0 +1,12 @@ +/* Keep profile SVG content (axes/labels) from being clipped */ +#profileSVG { + overflow: visible !important; +} + +/* Improve axis label readability against varying backgrounds */ +#profile_window .axis text { + paint-order: stroke; + stroke: #000; + stroke-width: 3px; + stroke-linejoin: round; +} diff --git a/src/2DProfileOverride/2DProfileOverride.js b/src/2DProfileOverride/2DProfileOverride.js new file mode 100644 index 0000000..721a67c --- /dev/null +++ b/src/2DProfileOverride/2DProfileOverride.js @@ -0,0 +1,224 @@ +import { ecef, wgs84 } from '../config.js' + +/** + * Initialize runtime overrides for Potree 2D profile behavior. + * - Patches ProfileWindow.addPoints: converts each point's Z to elevation instead. + * - Rewrites selection info table to show lon/lat/elevation. + */ +export function init2DProfileOverride(viewer) { + const tryPatchAddPoints = (target) => { + if (!target || target.__elevationPatchApplied) return false + const originalAddPoints = target.addPoints + if (typeof originalAddPoints !== 'function') return false + + target.addPoints = function patchedAddPoints(pointcloud, points) { + try { + if (!points || !points.data || !points.data.position) { + return originalAddPoints.call(this, pointcloud, points) + } + + const srcPos = points.data.position + // Divide by 3 because positions are [x0,y0,z0, x1,y1,z1, ...] + const count = points.numPoints || Math.floor(srcPos.length / 3) + const posArray = srcPos.constructor || Float32Array + + // Clone without changing original buffers + const cloned = { + ...points, + data: { ...points.data, position: new posArray(srcPos) } + } + const dstPos = cloned.data.position + + const pcx = pointcloud?.position?.x || 0 + const pcy = pointcloud?.position?.y || 0 + const pcz = pointcloud?.position?.z || 0 + + // Preserve world ECEF Z per point (best-effort). Some Potree UIs attach + // selectedPoint from these attributes; we keep it on the cloned structure + // for later retrieval in the selection panel override. + const ecefZWorld = new Float64Array(count) + + for (let i = 0; i < count; i++) { + const ix = 3 * i + const x = srcPos[ix + 0] + pcx + const y = srcPos[ix + 1] + pcy + const z = srcPos[ix + 2] + pcz + + const [, , elevation] = proj4(ecef, wgs84, [x, y, z]) + // Internally, Potree adds pointcloud.position.z back later. + dstPos[ix + 2] = elevation - pcz + + ecefZWorld[i] = z + } + + cloned.data.ecefZWorld = ecefZWorld + + const result = originalAddPoints.call(this, pointcloud, cloned) + + // Try to tag currently selectedPoint with the original ECEF Z if available + try { + if ( + this && + this.selectedPoint && + Number.isFinite(this.selectedPoint.index) + ) { + const idx = this.selectedPoint.index + if (ecefZWorld && idx >= 0 && idx < ecefZWorld.length) { + this.selectedPoint.ecefZWorld = ecefZWorld[idx] + } + } + } catch {} + + return result + } catch (err) { + console.warn( + '2DProfileOverride: failed to apply elevation override', + err + ) + return originalAddPoints.call(this, pointcloud, points) + } + } + + target.__elevationPatchApplied = true + return true + } + + let patched = false + if (viewer && viewer.profileWindow) { + patched = tryPatchAddPoints(viewer.profileWindow) + } + if (window.Potree?.ProfileWindow?.prototype) { + patched = + tryPatchAddPoints(window.Potree.ProfileWindow.prototype) || patched + } + + const afterPatched = () => { + attachSelectionInfoOverride(viewer) + } + + if (patched) { + afterPatched() + } else { + // Poll until the profile window/prototype is available + let tries = 0 + const maxTries = 40 + const timer = setInterval(() => { + tries += 1 + if ( + tryPatchAddPoints(window.Potree?.ProfileWindow?.prototype) || + (viewer?.profileWindow && tryPatchAddPoints(viewer.profileWindow)) + ) { + clearInterval(timer) + afterPatched() + } else if (tries >= maxTries) { + clearInterval(timer) + } + }, 250) + } +} + +// Rewrites the selection properties table inside the profile window +function attachSelectionInfoOverride(viewer) { + const getInfoEl = () => document.getElementById('profileSelectionProperties') + let infoEl = getInfoEl() + if (!infoEl) { + const obs = new MutationObserver(() => { + infoEl = getInfoEl() + if (infoEl) { + obs.disconnect() + monitorSelectionInfo(viewer, infoEl) + } + }) + obs.observe(document.body, { childList: true, subtree: true }) + } else { + monitorSelectionInfo(viewer, infoEl) + } +} + +function monitorSelectionInfo(viewer, infoEl) { + let scheduled = false + let selfUpdating = false + + const updateOnce = () => { + scheduled = false + selfUpdating = true + try { + const table = infoEl.querySelector('table') + if (!table) return + const rows = [...table.querySelectorAll('tr')] + if (rows.length < 3) return + + const pw = viewer?.profileWindow + const pos = pw?.viewerPickSphere?.position + const sp = pw?.selectedPoint + if (!pos || !sp) return + + // Use preserved ECEF Z if we have it; otherwise fall back to pos.z + const trueZ = Number(sp?.ecefZWorld ?? NaN) + let lon, lat + if (Number.isFinite(trueZ)) { + ;[lon, lat] = proj4(ecef, wgs84, [pos.x, pos.y, trueZ]) + } else { + ;[lon, lat] = proj4(ecef, wgs84, [pos.x, pos.y, pos.z]) + } + const elevation = pos.z + + const setRow = (row, label, val) => { + const tds = row.querySelectorAll('td') + if (tds[0] && tds[0].textContent !== label) tds[0].textContent = label + if (tds[1]) { + const txt = Number.isFinite(val) + ? val.toLocaleString(undefined, { + minimumFractionDigits: 4, + maximumFractionDigits: 4 + }) + : '' + if (tds[1].textContent !== txt) tds[1].textContent = txt + } + } + + setRow(rows[0], 'lon', lon) + setRow(rows[1], 'lat', lat) + setRow(rows[2], 'elevation', elevation) + + // Remove unwanted rows from the hover info table + const labelsToHide = new Set([ + 'intensity', + 'return number', + 'number of returns', + 'classification flags', + 'classification', + 'user data', + 'scan angle', + 'gps time', + 'gps-time', + 'rgba' + ]) + + // iterate over a snapshot to avoid issues while removing + const allRows = [...table.querySelectorAll('tr')] + for (let i = 3; i < allRows.length; i++) { + const row = allRows[i] + const labelCell = row.querySelector('td') + const label = (labelCell?.textContent || '').trim().toLowerCase() + if (labelsToHide.has(label)) { + row.remove() + } + } + } finally { + setTimeout(() => { + selfUpdating = false + }, 0) + } + } + + const mo = new MutationObserver(() => { + if (selfUpdating) return + if (!scheduled) { + scheduled = true + requestAnimationFrame(updateOnce) + } + }) + mo.observe(infoEl, { childList: true, subtree: true }) + requestAnimationFrame(updateOnce) +} diff --git a/src/potreeViewer.js b/src/potreeViewer.js index ab6c461..5baef9a 100644 --- a/src/potreeViewer.js +++ b/src/potreeViewer.js @@ -3,6 +3,7 @@ import { initMeasurementsPanel } from './MeasurementControl/measurementsPanel.js import { initMiniMap } from './MiniMap/miniMap.js' import { initFilterPanels, toggleAcceptedLegend } from './Filter/filter.js' import { ecef } from './config.js' +import { init2DProfileOverride } from './2DProfileOverride/2DProfileOverride.js' /** * Initializes the Potree viewer used to visualize the point cloud. @@ -125,6 +126,9 @@ export async function createPotreeViewer( // Show compass viewer.compass.setVisible(true) + // Apply runtime overrides for the 2D Profile tool + init2DProfileOverride(viewer) + initMeasurementsPanel(viewer) initAnnotationsPanel(viewer) initMiniMap(viewer)