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 = `
+
+
+
+
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']