diff --git a/index.html b/index.html index d1e6555..165c847 100644 --- a/index.html +++ b/index.html @@ -38,8 +38,7 @@ rel="stylesheet" href="/src/MeasurementControl/measurementsPanel.css" /> - - + diff --git a/src/AcceptedFiltering/threePanels.css b/src/AcceptedFiltering/threePanels.css new file mode 100644 index 0000000..443a997 --- /dev/null +++ b/src/AcceptedFiltering/threePanels.css @@ -0,0 +1,163 @@ +/* ---------- Buttons (shared look) ---------- */ +/* Reuse your accepted button style for all four */ +#btnDoElevationControl, +#doAcceptedHost, +#btnTHU, +#btnTVU, +#btnTHUFilter { + display: flex; + width: 100%; + margin: 6px 0 10px; + padding: 10px 10px; + font-size: 13px; + font-weight: 500; + background-color: #636262; + color: #ffffff; + border: 1px solid #555; + border-radius: 4px; + cursor: pointer; + transition: + background-color 0.2s ease, + transform 0.1s ease; +} + +#btnDoElevationControl:hover, +#doAcceptedHost:hover, +#btnTHU:hover, +#btnTVU:hover, +#btnTHUFilter:hover { + background-color: #8f8f8f; +} + +#btnDoElevationControl:active, +#doAcceptedHost:active, +#btnTHU:active, +#btnTVU:active, +#btnTHUFilter:active { + transform: scale(0.97); + background-color: #a8a6a6; +} + +/* Optional: “active mode” outline if you toggle a class via JS */ +#btnDoElevationControl.active, +#doAcceptedHost.active, +#btnTHU.active, +#btnTVU.active, +#btnTHUFilter:active { + outline: 2px solid #7ba8ff; + outline-offset: 1px; +} + +/* THU/TVU side-by-side */ +#thu_tvu_list .thu-tvu-row { + display: flex; + gap: 6px; +} +#thu_tvu_list .thu-tvu-row > button { + flex: 1 1 50%; + margin: 0; +} + +/* ---------- Panels / moved containers ---------- */ +/* Keep Potree’s moved subtrees neat and full-width inside our panels */ +#elevation2_list [id='materials.elevation_container'], +#thu_tvu_list [id='materials.extra_container'] { + width: 100%; + box-sizing: border-box; + padding: 6px 8px; /* small breathing room since we moved it out of Appearance */ +} + +/* Slight spacing inside our panel lists (under the button) */ +#elevation2_list, +#accepted_list_host, +#thu_tvu_list { + display: block; + padding: 4px 0; +} + +/* A small notice style used when an attribute/panel isn’t available */ +.panel-notice { + font-size: 13px; + color: #ddd; + background: rgba(255, 255, 255, 0.04); + border: 1px dashed #666; + border-radius: 4px; + padding: 8px; + margin: 6px 0; +} + +/* ---------- Elevation label + slider tweaks ---------- */ +#lblHeightRange { + display: inline-block; + font-size: 12px; + color: #ddd; + margin: 4px 0 6px 2px; +} + +/* Make sure the jQuery-UI slider has a little vertical room */ +#sldHeightRange { + margin: 6px 4px 10px 4px; +} + +/* ---------- Accordions / headers (light touch) ---------- */ +/* Don’t fight jQuery-UI’s theme. Just small spacing adjustments. */ +#menu_elevation2 + .pv-menu-list, +#menu_accepted + .pv-menu-list, +#menu_thu_tvu + .pv-menu-list { + padding-top: 6px; +} + +/* Optional: header label color alignment with dark UI */ +#menu_elevation2 span, +#menu_accepted span, +#menu_thu_tvu span { + color: #e6e6e6; + font-weight: 600; + letter-spacing: 0.2px; +} + +/* Row layout for THU / TVU buttons */ +#thu_tvu_list .thu-tvu-row { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +/* Each button takes up half the width nicely */ +#thu_tvu_list .thu-tvu-row button { + flex: 1 1 50%; + margin: 0; /* override any global margin */ +} + +/* Legend container */ +#accepted_legend { + margin-top: 10px; + padding-left: 4px; +} + +/* Legend rows */ +#accepted_legend .legend-row { + display: flex; + align-items: center; + margin: 3px 0; + font-size: 13px; + color: #ddd; /* visible text */ +} + +/* Color boxes */ +#accepted_legend .legend-color { + width: 16px; + height: 16px; + border: 1px solid #777; + margin-right: 8px; + border-radius: 2px; +} + +#accepted_legend .legend-color.accepted { + background-color: #fff; +} + +#accepted_legend .legend-color.not-accepted { + background-color: #000; +} diff --git a/src/AcceptedFiltering/threePanels.js b/src/AcceptedFiltering/threePanels.js new file mode 100644 index 0000000..1da250d --- /dev/null +++ b/src/AcceptedFiltering/threePanels.js @@ -0,0 +1,439 @@ +// Three Potree sidebar sections with buttons + panel bodies: +// • Elevation → moves #materials.elevation_container into our Elevation body +// • Accepted → custom UI fully defined here (no external module) + +const byId = (id) => document.getElementById(id) + +/** + * Refreshes the jQuery-UI accordion after DOM mutations. + */ +function accordionRefresh() { + const menu = byId('potree_menu') + if (window.$ && menu && window.$(menu).accordion) { + try { + window.$(menu).accordion('refresh') + } catch {} + } +} + +/** + * Inserts a sidebar section with a header and a panel container. + * Places it before Appearance/Filters if present, otherwise at the end. + * @param {{headerId:string, headerText:string, listId:string}} + */ +function insertSection({ headerId, headerText, listId }) { + if (byId(listId)) return + + const menu = byId('potree_menu') + if (!menu) return + + const header = document.createElement('h3') + header.id = headerId + header.innerHTML = `${headerText}` + + const panel = document.createElement('div') + panel.className = 'pv-menu-list' + panel.innerHTML = `
` + + const before = byId('menu_appearance') || byId('menu_filters') || null + if (before) { + menu.insertBefore(panel, before) + menu.insertBefore(header, panel) + } else { + menu.appendChild(header) + menu.appendChild(panel) + } + + accordionRefresh() +} + +/** + * Ensures buttons row and a body container to new panel + * @param {string} listId + * @returns {{list:HTMLElement|null, btns:HTMLElement|null, body:HTMLElement|null}} + */ +function ensurePanelScaffold(listId) { + const list = byId(listId) + if (!list) return { list: null, btns: null, body: null } + + let btns = list.querySelector('.panel-buttons') + let body = list.querySelector('.panel-body') + + if (!btns) { + btns = document.createElement('div') + btns.className = 'panel-buttons' + list.insertBefore(btns, list.firstChild) + } + if (!body) { + body = document.createElement('div') + body.className = 'panel-body' + list.appendChild(body) + } + return { list, btns, body } +} + +/** + * Shows only the requested body to make for a better visual experience by the user (buttons remain visible at all times). + * @param {'elevation'|'accepted'} key + */ +function showOnly(key) { + const elevBody = byId('elevation2_list')?.querySelector('.panel-body') + const accBody = byId('accepted_list_host')?.querySelector('.panel-body') + + if (elevBody) elevBody.style.display = key === 'elevation' ? '' : 'none' + if (accBody) accBody.style.display = key === 'accepted' ? '' : 'none' +} + +/** + * Clicks a button only once (used for auto-activation of elevation upon opening the application). + * @param {string} id + */ +function clickOnce(id) { + const btn = byId(id) + if (btn && !btn.dataset.autoClicked) { + btn.dataset.autoClicked = 'true' + btn.click() + } +} + +/** + * Opens the Scene accordion so the Properties/Attributes UI renders and can be moved to customized sections. + */ +function openScenePane() { + const menu = byId('potree_menu') + const header = byId('menu_scene') + if (!menu || !header) return + + if (window.$ && window.$(menu).accordion) { + try { + const headers = [...menu.querySelectorAll(':scope > h3')] + const idx = headers.findIndex((h) => h.id === 'menu_scene') + if (idx >= 0) window.$(menu).accordion('option', 'active', idx) + } catch {} + } else { + header.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } +} + +/** + * Ensures a cloud node is selected so the Properties of the Scene renders. + * @param {{selectCloudOnce?:Function}} hooks + */ +function selectCloudNode(hooks) { + if (typeof hooks?.selectCloudOnce === 'function') { + try { + hooks.selectCloudOnce() + return + } catch {} + } + const icon = document.querySelector( + '#scene_objects i.jstree-themeicon-custom' + ) + if (icon) icon.dispatchEvent(new MouseEvent('click', { bubbles: true })) +} + +/** + * Polls the DOM for an element id so that wverything is ensured displayed. + * @param {string} id + * @param {number} softMs + * @param {number} pollEvery + * @returns {Promise} + */ +async function waitForOrPoll(id, softMs = 1400, pollEvery = 120) { + const start = performance.now() + while (performance.now() - start < softMs) { + const el = byId(id) + if (el) return el + await new Promise((r) => setTimeout(r, pollEvery)) + } + return null +} + +/* ───────────────────────────── Elevation panel ───────────────────────────── */ +/** + * Inserts the Elevation panel section and scaffold. + */ +function createElevationPanel() { + insertSection({ + headerId: 'menu_elevation', + headerText: 'Elevation Control', + listId: 'elevation2_list' + }) + ensurePanelScaffold('elevation2_list') +} + +/** + * Ensures the Elevation action button is present and wired. + * @param {{onActivateElevation?:Function}} hooks + */ +function ensureElevationButton(hooks) { + const { btns } = ensurePanelScaffold('elevation2_list') + if (!btns || byId('btnDoElevationControl')) return + + const btn = document.createElement('button') + btn.id = 'btnDoElevationControl' + btn.type = 'button' + btn.textContent = 'Activate elevation control' + btn.addEventListener('click', () => { + switchMode('elevation', hooks?.onActivateElevation, hooks) + }) + btns.appendChild(btn) +} + +/** + * Reconnects the Elevation slider label to reflect the current range. + * Assumes #sldHeightRange and #lblHeightRange exist. + */ +function rebindElevationLabel() { + const $ = window.jQuery || window.$ + const slider = $ ? $('#sldHeightRange') : null + const label = byId('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)}` + } + + slider.slider({ min: -10000, max: 0, values: [-10000, 0] }) + slider.off('slide.custom slidestop.custom change.custom') + slider.on('slide.custom', update) + slider.on('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() { + const { body } = ensurePanelScaffold('elevation2_list') + const src = byId('materials.elevation_container') + if (!body || !src) return false + + if (src.parentNode !== body) { + body.appendChild(src) + rebindElevationLabel() + accordionRefresh() + } + return true +} + +/** + * Initializes Elevation section and passive self-healing for rebuilds. + * @param {{onActivateElevation?:Function}} hooks + */ +function initElevationControls(hooks) { + createElevationPanel() + ensureElevationButton(hooks) + + const root = byId('potree_menu') || document.body + const obs = new MutationObserver(() => { + if (byId('materials.elevation_container')) moveElevationContainer() + }) + obs.observe(root, { childList: true, subtree: true }) +} + +/* ───────────────────────────── Accepted panel ───────────────────────────── */ + +/** + * Inserts the Accepted section and an inner host container. + */ +function createAcceptedPanel() { + insertSection({ + headerId: 'menu_accepted', + headerText: 'Accepted Filter', + listId: 'accepted_list_host' + }) + const { body } = ensurePanelScaffold('accepted_list_host') + + if (body && !byId('accepted_list')) { + const wrap = document.createElement('div') + wrap.id = 'accepted_list' + body.appendChild(wrap) + } +} + +/** + * Ensures the Accepted action button is present. + * @param {{onActivateAccepted?:Function}} hooks + */ +function ensureAcceptedButton(hooks) { + const { btns } = ensurePanelScaffold('accepted_list_host') + if (!btns || byId('doAcceptedHost')) return + + const btn = document.createElement('button') + btn.id = 'doAcceptedHost' + btn.type = 'button' + btn.textContent = 'Activate accepted filter' + btn.addEventListener('click', () => { + switchMode('accepted', hooks?.onActivateAccepted, hooks) + }) + btns.appendChild(btn) +} + +/** + * Creates legend under the Accepted list for accepted and not accepted indication. + * @returns {HTMLElement|null} + */ +function ensureAcceptedLegend() { + const list = byId('accepted_list') + if (!list) return null + + let legend = list.querySelector('#accepted_legend') + if (!legend) { + legend = document.createElement('div') + legend.id = 'accepted_legend' + legend.style.display = 'none' + legend.innerHTML = ` +
+
+ Accepted points +
+
+
+ Not accepted points +
+ ` + list.appendChild(legend) + } + return legend +} + +/** + * Shows/hides legend based on the hook (if the button is clicked or not) + * + * @param {*} show + */ +export function toggleAcceptedLegend(show) { + const legend = byId('accepted_legend') + if (legend) legend.style.display = show ? 'block' : 'none' +} + +/** + * Ensures a UL list host exists inside the Accepted section (Does appearnlty nor work without this). + * @returns {HTMLElement|null} + */ +function ensureAcceptedListUL() { + const host = byId('accepted_list') + if (!host) return null + + let ul = host.querySelector('#accepted_ui') + if (!ul) { + ul = document.createElement('ul') + ul.id = 'accepted_ui' + ul.className = 'pv-menu-list' + host.appendChild(ul) + } + return ul +} + +/** + * Initializes the Accepted section. + * @param {{onActivateAccepted?:Function}} hooks + */ +function initAcceptedControlsInline(hooks) { + createAcceptedPanel() + ensureAcceptedButton(hooks) + ensureAcceptedLegend() + ensureAcceptedListUL() +} + +/** + * Ensures the correct Potree panel is present and moved for the requested filtering + * @param {'elevation'|'accepted'} mode + * @param {object} hooks + */ +async function ensurePanelCaptured(mode, hooks) { + openScenePane() + selectCloudNode(hooks) + + if (mode === 'elevation') { + let src = + byId('materials.elevation_container') || + (await waitForOrPoll('materials.elevation_container')) + if (!src && typeof hooks?.onActivateElevation === 'function') { + // nudge: switch away and back if needed to force Potree to build the UI + hooks.onActivateAccepted?.() + openScenePane() + selectCloudNode(hooks) + hooks.onActivateElevation() + openScenePane() + selectCloudNode(hooks) + src = await waitForOrPoll('materials.elevation_container', 1800) + } + if (src) moveElevationContainer() + return + } +} + +/** + * Central mode switcher. Calls the provided hook, captures/moves the panel, + * toggles Accepted legend, and reveals the correct panel body. + * @param {'elevation'|'accepted'} mode + * @param {Function|undefined} hook + * @param {object} hooksBag + */ +let switching = false +async function switchMode(mode, hook, hooksBag = {}) { + if (switching) return + switching = true + try { + if (typeof hook === 'function') hook() + await ensurePanelCaptured(mode, hooksBag) + toggleAcceptedLegend(mode === 'accepted') + if (mode === 'elevation') showOnly('elevation') + else if (mode === 'accepted') showOnly('accepted') + } finally { + switching = false + } +} + +/** + * Observes the sidebar DOM and re-moves containers if Potree rebuilds them. + * @param {()=>'elevation'|'accepted'} activeGetter + * @returns {MutationObserver} + */ +function attachSelfHealing(activeGetter) { + 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() + } + }) + obs.observe(root, { childList: true, subtree: true }) + return obs +} + +/* ───────────────────────────── Initiation ───────────────────────────── */ +/** + * 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 + */ +export function initThreePanels(viewer, hooks = {}) { + // Build sections + initElevationControls(hooks) + initAcceptedControlsInline(hooks) + + // Track active section for self-healing + let active = 'elevation' + const setActive = (m) => { + active = m + } + const getActive = () => active + + byId('btnDoElevationControl')?.addEventListener('click', () => + setActive('elevation') + ) + byId('doAcceptedHost')?.addEventListener('click', () => setActive('accepted')) + + attachSelfHealing(getActive) + + // Default: auto-activate Elevation once + clickOnce('btnDoElevationControl') +} diff --git a/src/potreeViewer.js b/src/potreeViewer.js index 51eab3a..bfa96a4 100644 --- a/src/potreeViewer.js +++ b/src/potreeViewer.js @@ -1,9 +1,8 @@ -import { initElevationControls } from './ElevationControl/elevationControl.js' import { initMeasurementsPanel } from './MeasurementControl/measurementsPanel.js' import { - initAcceptedControls, + initThreePanels, toggleAcceptedLegend -} from './Accepted/accepted.js' +} from './AcceptedFiltering/threePanels.js' import { ecef } from './config.js' /** @@ -24,6 +23,10 @@ export async function createPotreeViewer(containerId, pointcloudUrl, settings) { oc.removeEventListener('mousewheel', oc._listeners?.mousewheel?.[0]) oc.addEventListener('mousewheel', clampScrollRadius) + const e = await Potree.loadPointCloud(pointcloudUrl) + const pc = e.pointcloud + viewer.scene.addPointCloud(pc) + if (settings.edl) viewer.setEDLEnabled(true) if (settings.fov) viewer.setFOV(settings.fov) if (settings.pointBudget) viewer.setPointBudget(settings.pointBudget) @@ -39,73 +42,32 @@ export async function createPotreeViewer(containerId, pointcloudUrl, settings) { $('#menu_filters').next().show() viewer.toggleSidebar() - /** - * initiates the elevation control panel - */ - initElevationControls(viewer, { + initThreePanels(viewer, { onActivateElevation: () => { - const pc = viewer.scene.pointclouds[0] - if (!pc) { - console.warn('[Elevation] No point cloud loaded yet.') - return - } - // Switch to elevation coloring + if (!pc) return pc.material.activeAttributeName = 'elevation' pc.material.gradient = Potree.Gradients['VIRIDIS'] - - //supress autoscroll when the cloud icon is activated - suppressSidebarAutoScroll(() => { - const cloudIcon = document.querySelector( - '#scene_objects i.jstree-themeicon-custom' - ) - if (cloudIcon) { - cloudIcon.dispatchEvent(new MouseEvent('click', { bubbles: true })) - } - }) - - // One-shot render - viewer.render() - } - }) - - /** - * initiates filtering based on the "accepted" attribute of each point - */ - initAcceptedControls(viewer, { + suppressSidebarAutoScroll(clickCloudIconOnce) + }, onActivateAccepted: () => { - const pc = viewer.scene.pointclouds[0] - if (!pc) { - console.warn('[Accepted] No point cloud loaded yet.') - return - } - // Switch to attribute-based coloring using the 'accepted' attribute and color by GRAYSCALE + if (!pc) return pc.material.activeAttributeName = 'accepted' pc.material.gradient = Potree.Gradients['GRAYSCALE'] - - //supress autoscroll when the cloud icon is activated - suppressSidebarAutoScroll(() => { - const cloudIcon = document.querySelector( - '#scene_objects i.jstree-themeicon-custom' - ) - if (cloudIcon) { - cloudIcon.dispatchEvent(new MouseEvent('click', { bubbles: true })) - } - }) - // One-shot render - viewer.render() - - //makes sure the panel show upon initiation toggleAcceptedLegend(true) + suppressSidebarAutoScroll(clickCloudIconOnce) } }) + // // // helper + function clickCloudIconOnce() { + const icon = document.querySelector( + '#scene_objects i.jstree-themeicon-custom' + ) + if (icon) icon.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } 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') @@ -118,7 +80,7 @@ export async function createPotreeViewer(containerId, pointcloudUrl, settings) { pc.material.shape = Potree.PointShape.CIRCLE overrideShaderForGradient(pc) - //The default activeAttributeName is set to elevation and the color gradient to VIRIDIS + //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']