diff --git a/README.md b/README.md index 7b38baf..8024b97 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,7 @@ Then in a new terminal run the tests: ```bash npm run test ``` + +### User Guide + +For a complete user guide on fundamentals and all functionality click [here](Userguide.md). diff --git a/Userguide.md b/Userguide.md new file mode 100644 index 0000000..35ff83a --- /dev/null +++ b/Userguide.md @@ -0,0 +1,443 @@ +# User Guide + +This is a user guide for all functionality found in Molloy Explorer. For general information and SetUp, see [README](README.md). + +## Table of Contents + +- [Fundamentals](#fundamentals) + - [Moving the viewpoint](#moving-the-viewpoint) + - [Zoom](#zoom) + - [Move](#move) +- [Sidebar](#functionalities) + - [Elevation Control](#elevation-control) + - [Accepted Filter](#accepted-filter) + - [Measurements](#measurements) + - [Saved Locations](#saved-locations) + - [Appearance](#appearance) + - [Tools](#tools) + - [Scene](#scene) + - [About](#about) +- [Minimap](#minimap) +- [Coordinates](#coordinates) + +## Fundamentals + +### Moving the Viewpoint + +Left click and hold anywhere on the screen, while moving the mouse or use a second finger on the touchpad to dynamically move the viewpoint. By doing this you can tilt and rotate the viewpoint. + +### Zoom + +To zoom in and out the user can either: +Utilize the scroll wheel on a mouse. +Drag two fingers on the touchpad towards each other or away from each other. + +### Move + +To move within the application, double click on a point you wish to explore further and you will automatically be moved closer to that point. +If you want to move across larger distances, for example look at a point from a different point cloud, it is recommended to zoom out first and then double click on a point closer to where you want to be positioned. + +## Sidebar + +In the top left corner there are two icons: +The top icon opens the sidebar and the bottom icon shows and collapses the minimap. Most of the functionalities are located in the sidebar. + + icons + +There are eight tabs in the sidebar that can be opened and collapsed by clicking on them. + +tabs + +### Elevation Control + +--- + +Elevation control applies gradients based on the elevation of the data points. +By default elevation control is active. The “Activate elevation control” button will be visible and can be pressed after looking at the Accepted Filter to reset the view back to default. See image below. +Use the slider to set the range by clicking and dragging the squares on either end of the scale. +Gradient is set to Clamp by default, but can be changed to “Repeat” or “Mirrored Repeat” by clicking on them. +There are nine different gradient schemes to choose from. These are also selected by clicking on them. + +ElevationControl + +ElevationControl + +### Accepted Filter + +--- + +The accepted filter contains one button to change the view to show which datapoints are accepted as part of the seabed, when the elevation control is active. When the “Activate accepted filter” button is pressed, accepted points are displayed in white and not accepted points are displayed in black. This data regarding if a point is accepted or not is an attribute of each datapoint and can not be changed. + +AcceptedFilter + +AcceptedFilter + +### Measurements + +--- + +The Measurements tab allows you to measure angles, distances, heights, areas, volumes, and other properties directly within the point cloud. All tools are located in the Measurements tab in the sidebar. You can perform multiple measurements simultaneously, and all results are tracked in the "List of Measurements" section. + +For all the measurements below, you stop by right clicking. + +Measurements + +#### Measure Angle + +To measure angles between points: + +1. Click on the "Measure Angle" icon or text. +2. Click three points in the point cloud. A red triangle will appear connecting the points, and the corresponding angle will be displayed. + +To adjust a point in the triangle, click on the point you want to move, then click a new point in the point cloud. +There is no limit to the number of triangles you can display. +Measurement details appear in the "List of Measurements" below the sidebar. The default name is "Distance#1"; make sure it is selected when reviewing the data. + +#### Inspect Point + +To inspect a point in the point cloud: + +1. Click the "Inspect Point" icon. +2. Click on the point you want to examine. + +The coordinates and other available attributes of that point will be displayed in the corresponding panel. + +#### Measure Distance + +To measure the distance between two points: + +1. Click the "Measure Distance" icon. +2. Click the first point, then the second point in the point cloud. + +The distance between the points will be displayed, and a connecting line will appear. +To adjust points, click the point and select a new location. + +#### Measure Height + +To measure vertical height differences: + +1. Click the "Measure Height" icon. +2. Click the lower point first, then the higher point. + +The vertical distance will be displayed. + +#### Circle + +To measure a circle: + +1. Click the "Circle" icon. +2. Click the center point, then a point on the circumference. + +The radius and circumference will be displayed. + +#### Azimuth + +To measure azimuth (direction relative to north): + +1. Click the "Azimuth" icon. +2. Click the starting point, then the ending point. + +Azimuth measures the directional angle in degrees between two points relative to true north. + +#### Area + +To measure a polygonal area: + +1. Click the "Area" icon. +2. Click points to form the vertices of the polygon. +3. Double-click the last point to complete the polygon. + +The calculated area will be displayed. + +#### Volume + +To measure a 3D volume: + +1. Click the "Volume" icon. +2. Select points to define the base polygon of the volume. +3. Click an additional point to define the top plane (height). + +The volume enclosed by the selected points will be calculated. + +#### Sphere Volume + +To measure a sphere’s volume: + +1. Click the "Sphere Volume" icon. +2. Click the center point of the sphere, then a point on the surface to define the radius. + +The sphere volume will be displayed. + +#### 2D Height Profile + +To generate a 2D height profile along a line: + +1. Click the "2D Height Profile" icon. +2. Click the starting point of the profile line, then click the ending point. +3. Click the "show 2D profile". + +A 2D plot of height along the line will be displayed. +You can hover over or click points on the profile to see specific elevation values. +Multiple profile lines can be drawn for comparison. +To adjust the profile line, select a point and move it to a new location. + +#### Remove All + +Click the "Remove All" icon to clear all measurements currently displayed in the scene. This will remove all lines, polygons, volume measurements, height profiles, and labels at once. + +#### Deleting Measurements + +To delete individual measurements: + +1. Select the measurement in the "List of Measurements". +2. Click the red "x" icon next to the measurement. + +This applies to all measurement types including angles, distances, heights, areas, volumes, spheres, circles, azimuths, and 2D height profiles. + +#### Values/Names/Hide All Labels + +To show or hide Values or Names for measurements: + +1. Click the "Values" or "Names" button to display it for all measurements currently in the scene. +2. Click the "Hide All" button to hide all measurement labels without deleting the measurements themselves. + +This applies to all measurement types, including angles, distances, heights, areas, volumes, spheres, circles, azimuths, and 2D height profiles. + +#### List of Measurements + +All measurements are tracked in the "List of Measurements" panel at the bottom of the sidebar. Each measurement can be selected to view detailed information or deleted individually. This list provides an easy way to manage multiple measurements in a large point cloud. + +### Saved Locations + +--- + +The Saved Locations tab allows you to quickly navigate to specific points or viewpoints that you have saved within the scene. Annotations can also be created here to mark points of interest. By default, annotation labels are visible in the point cloud and on the map, making it easy to locate important areas. + +SavedLocations + +SavedLocations + +#### Show/Hide Annotations + +Below the list of annotations, there are toggle buttons to show or hide all annotation labels in the point cloud. Use this to declutter the view or focus on specific points as needed. + +#### Creating an Annotation + +To create an annotation: + +1. Click the "Add a Location" button in the Saved Locations section. +2. Click on a point in the point cloud. + +As you hover over points, an annotation label will appear above the cursor to indicate that a point can be selected. +The annotation will be added to a list right below the "Add a Location" button upon creation. + +#### Managing Annotations + +Each annotation in the list has three icons: + +- **Pen Icon** – Rename the annotation or edit its description. +- **Arrow Icon** – Move the camera to the annotation from anywhere in the scene. +- **Red X Icon** – Delete the annotation. + +To add a description to an annotation: + +1. Click the annotation text in the Saved Locations list. +2. Click the pen icon next to "Annotation Description". + +The point coordinates and the current camera coordinates will be displayed below the description field. + +### Appearance + +--- + +The Appearance tab allows you to adjust the visual rendering and display settings of the point cloud and scene to optimize clarity, performance, and visual preference. + +Appearance + +#### Point Budget + +The Point Budget controls the maximum number of points rendered at any time. + +- Adjustable using a slider ranging from **100,000 to 10,000,000 points**. +- Higher values provide more detail but may impact performance, while lower values improve performance at the cost of detail. + +#### Field of View + +The Field of View (FOV) controls the camera’s viewing angle. + +- Adjustable using a slider ranging from **20° to 100°**. +- A wider FOV shows more of the scene but may distort perspective, while a narrower FOV provides a more focused view. + +#### Eye-Dome Lighting (EDL) + +Eye-Dome Lighting enhances depth perception by shading points based on their relative distance. + +- **Enable Checkbox** – Turn EDL on or off. +- **Radius Slider** – Adjusts the size of the area used to calculate shading. +- **Strength Slider** – Controls the intensity of the depth effect. +- **Opacity Slider** – Adjusts the transparency of the shading effect. + +#### Background + +The Background setting controls the scene’s visual backdrop. Options include: + +- **Skybox** – A textured 3D background simulating a sky. +- **Gradient** – Smooth transition between two colors. +- **Black** – Solid black background. +- **White** – Solid white background. +- **Globe** – Displays the Earth or a globe representation, this is the default. + +#### Other Settings + +- **Splat Quality** – Adjusts the rendering quality of point splats: + - **Standard** – Default quality, optimized for performance. + - **High Quality** – Improved visual detail, may impact performance. +- **Min Node Size** – Controls the smallest size of nodes rendered in the point cloud. +- **Box** – Toggle the visibility of bounding boxes around point clouds or objects. +- **Lock View** – Keeps the bounding boxes or other visual guides in a fixed position. + +### Tools + +--- + +The Tools section is located in the sidebar and contains functionalities for clipping, navigation, camera control, and speed adjustment. Clicking on a tool will open the corresponding options. + +Tools + +#### Clipping + +Clipping allows you to focus on specific regions of the point cloud or dataset by hiding or highlighting data based on user-defined areas. + +- **Volume Clip** + Lets you define a 3D box or other volume to clip the data inside or outside of it. This is useful when you want to focus on a particular region of the seabed or point cloud. + +- **Polygon Clip** + Allows you to draw a polygon on the screen and clip data based on the shape of that polygon. Only data inside or outside the polygon will be affected depending on your settings. + +- **Draw a Selection Box** + Enables you to quickly create a rectangular selection in the scene. This box can then be used as a clipping volume to isolate a section of the dataset. + +- **Remove All Clipping Volumes** + Resets all clipping operations and displays the entire dataset again. Use this when you want to start over or remove all applied selections. + +#### Clip Task + +The Clip Task determines what happens to the points in the clipping area: + +- **None**: No changes are applied to the points within the clip volume. +- **Highlight**: Points within the clipping area are highlighted to make them easier to see. +- **Inside**: Only points inside the clipping area remain visible; all others are hidden. +- **Outside**: Only points outside the clipping area remain visible; all points inside are hidden. + +#### Clip Method + +When multiple clipping volumes are active, the Clip Method determines how they are combined: + +- **Inside Any**: Points inside any of the clipping volumes will be affected. This creates a union of all clipping areas. +- **Inside All**: Points must be inside all defined clipping volumes to be affected. This creates an intersection of all volumes. + +#### Navigation + +Navigation tools allow you to move around the dataset and view it from different angles or perspectives. + +- **Earth Control** + Moves the camera around the globe, allowing rotation, panning, and tilting. Useful for general exploration of the point cloud relative to the Earth’s surface. + +- **Fly Control** + Simulates a flying camera, allowing smooth free movement in any direction. Useful for inspecting large areas of the seabed. + +- **Helicopter Control** + Similar to Fly Control, but movement mimics the behaviour of a helicopter, including tilt and banking effects. + +- **Orbit Control** + Lets the camera orbit around a selected point, object, or region. Ideal for inspecting objects from multiple angles without changing the target point. This is the default mode. + +- **Full Extent** + Resets the view to show the entire dataset or point cloud. This is useful if you have zoomed in too far or moved to a specific area and want to see the whole scene. + +- **Navigation Cube** + A visual cube in the scene that allows you to quickly change views by clicking on its faces, edges, or corners. It can switch between standard perspectives like top, side, or front views. + +- **Compass** + Displays a compass in the top right corner of the screen. This shows the current orientation and allows you to reset the view to north-up. + +- **Camera Animation** + Lets you create or play smooth camera movements, such as flying along a path or rotating around a point. To utilize this tool, click on the Scene tab, then go to Objects/Other, an animation icon will show up. Click on this, adjust properties as wanted(you can move the points or adjust the duration of the animation here), and click on play. + + Scene + +- **Left View / Right View / Front View / Back View / Top View / Bottom View** + Predefined fixed perspectives that allow you to quickly switch to standard orthogonal views of your dataset. + +#### Camera Projection + +Camera projection defines how depth and distance are perceived in the 3D view. + +- **Perspective** + Standard 3D view where objects appear smaller as they get farther away. This view simulates how humans naturally perceive depth. + +#### Speed + +The Speed control adjusts how fast the camera or navigation responds to user inputs. + +- Increasing speed makes zooming, panning, and flying faster, which is useful for exploring large datasets quickly. +- Decreasing speed allows for precise adjustments when inspecting small details or measuring specific points. +- The speed will be adjusted automatically when you zoom in and out, providing a more intuitive navigation experience. + +This section ensures that all users can efficiently navigate and manipulate the point cloud data, while providing control over the view, clipping, and camera behaviour. + +### Scene + +--- + +The Scene tab allows you to manage and organize all the elements currently loaded in the application. It provides tools to view, modify, and export components such as point clouds, measurements, annotations, and other objects, giving you complete control over the content of your 3D environment. + +Scene + +#### Export + +The Export feature allows you to save elements from the scene into different file formats for external use: + +1. **JSON** – Exports the scene configuration and metadata for use in other applications or for project backup. +2. **DXF** – Exports measurements and geometry into a CAD-compatible format. +3. **Potree** – Exports the point cloud and scene data into a Potree-compatible format for web-based visualization. + +#### Objects + +The Objects section lists all items currently present in the scene, grouped into categories: + +- **Point Clouds** – Displays all loaded point clouds in the scene. You can toggle visibility, inspect properties, or remove them. +- **Measurements** – See [Measurements panel](#measurements). +- **Annotations** – See [Saved Locations panel](#saved-locations). +- **Other** – Contains additional scene elements: + - **Camera** – Tracks the position and orientation of the current viewpoint. +- **Vectors** – Displays any vector elements present in the scene. +- **Images** – Shows any images linked to points or objects in the scene. + +#### Properties + +The Properties panel provides detailed information about the selected object in the scene. Depending on the object type, you can view: + +- Coordinates and dimensions +- Colors and intensity values +- Metadata and attributes +- Visibility and rendering settings + +This section gives you full control over the content, allowing you to inspect, modify, or export scene elements efficiently. + +### About + +--- + +General information regarding the application, licences, contributors and more will show up here. This is default information from Potree. + +## Minimap + +The map icon below the sidebar icon in the top left corner, will show and collapse a minimap. +In the minimap you can zoom using the mouse, touchpad or the icons in the top left corner. +You can move around by clicking and moving the mouse around simultaneously. The coordinates your cursor hover over will show up in the top right corner of the minimap. + +## Coordinates + +In the bottom right corner there is a black box that lists coordinates. These coordinates correspond to the target point, which is continuously updated to be the most recent point the user has double clicked on. Latitude and longitude are measured in degrees, while elevation is measured in metres from the sea level. +Coordinates are only shown in the "Orbit Control" Navigation mode. diff --git a/index.html b/index.html index 2f98510..b0488b6 100644 --- a/index.html +++ b/index.html @@ -39,7 +39,8 @@ href="/src/MeasurementControl/measurementsPanel.css" /> - + + diff --git a/public/img/00_icons.png b/public/img/00_icons.png new file mode 100644 index 0000000..1aef21c Binary files /dev/null and b/public/img/00_icons.png differ diff --git a/public/img/01_menuBarTabs.png b/public/img/01_menuBarTabs.png new file mode 100644 index 0000000..ac3a94d Binary files /dev/null and b/public/img/01_menuBarTabs.png differ diff --git a/public/img/02_ElevationControlTab.png b/public/img/02_ElevationControlTab.png new file mode 100644 index 0000000..26ef7b1 Binary files /dev/null and b/public/img/02_ElevationControlTab.png differ diff --git a/public/img/03_ElevationControlTab.png b/public/img/03_ElevationControlTab.png new file mode 100644 index 0000000..9b3fcdf Binary files /dev/null and b/public/img/03_ElevationControlTab.png differ diff --git a/public/img/04_AcceptedFilter.png b/public/img/04_AcceptedFilter.png new file mode 100644 index 0000000..ce218ad Binary files /dev/null and b/public/img/04_AcceptedFilter.png differ diff --git a/public/img/05_AcceptedFilter.png b/public/img/05_AcceptedFilter.png new file mode 100644 index 0000000..8dfa909 Binary files /dev/null and b/public/img/05_AcceptedFilter.png differ diff --git a/public/img/06_Measurements.png b/public/img/06_Measurements.png new file mode 100644 index 0000000..d24f47d Binary files /dev/null and b/public/img/06_Measurements.png differ diff --git a/public/img/07_SavedLocations.png b/public/img/07_SavedLocations.png new file mode 100644 index 0000000..31aa2e6 Binary files /dev/null and b/public/img/07_SavedLocations.png differ diff --git a/public/img/08_Appearance.png b/public/img/08_Appearance.png new file mode 100644 index 0000000..ab9f09e Binary files /dev/null and b/public/img/08_Appearance.png differ diff --git a/public/img/09_Tools.png b/public/img/09_Tools.png new file mode 100644 index 0000000..cd259db Binary files /dev/null and b/public/img/09_Tools.png differ diff --git a/public/img/10_Scene.png b/public/img/10_Scene.png new file mode 100644 index 0000000..045067a Binary files /dev/null and b/public/img/10_Scene.png differ diff --git a/public/img/11_SavedLocations.png b/public/img/11_SavedLocations.png new file mode 100644 index 0000000..83153ed Binary files /dev/null and b/public/img/11_SavedLocations.png differ diff --git a/public/img/12_Measurements_updated.jpg b/public/img/12_Measurements_updated.jpg new file mode 100644 index 0000000..f3aa31e Binary files /dev/null and b/public/img/12_Measurements_updated.jpg differ diff --git a/public/img/13_Tools_updated.jpg b/public/img/13_Tools_updated.jpg new file mode 100644 index 0000000..a8466dc Binary files /dev/null and b/public/img/13_Tools_updated.jpg differ diff --git a/public/img/14_Animation.jpg b/public/img/14_Animation.jpg new file mode 100644 index 0000000..8581e9d Binary files /dev/null and b/public/img/14_Animation.jpg differ 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/Accessibility/makeMenuTabbable.js b/src/Accessibility/makeMenuTabbable.js new file mode 100644 index 0000000..04821f3 --- /dev/null +++ b/src/Accessibility/makeMenuTabbable.js @@ -0,0 +1,492 @@ +/** + * Makes menu tabbable keyboard accessible. + */ +export function makeMenuTabbable() { + makeMenuToggleTabbable() + makeMiniMapTabbable() + makePanelsTabbable() + makeElevationControlTabbable() + makeAcceptedFilteringTabbable() + makeObjectsTabbable() + makeObjectsDropdownsTabbable() + makeMeasurementsTabbable() + makeAppearancePanelTabbable() + makeToolsPanelTabbable() +} + +/** + * Makes menu toggle tabbable and adds keyboard event listeners. + */ +function makeMenuToggleTabbable() { + const quickButtonsContainer = document.getElementById('potree_quick_buttons') + if (!quickButtonsContainer) return + const toggle = quickButtonsContainer.querySelector('.potree_menu_toggle') + if (toggle) { + toggle.tabIndex = 0 + toggle.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggle.click() + } + }) + } +} + +/** + * Makes minimap tabbable and keyboard clickable + */ +function makeMiniMapTabbable() { + const quickButtonsContainer = document.getElementById('potree_quick_buttons') + if (!quickButtonsContainer) return + const toggle = quickButtonsContainer.querySelector('#potree_map_toggle') + if (toggle) { + toggle.tabIndex = 0 + toggle.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggle.click() + } + }) + } +} + +/** + * Makes accordion titles tabbable and keyboard clickable + */ +function makePanelsTabbable() { + const menu = document.getElementById('potree_menu') + if (menu) { + const headers = menu.querySelectorAll('h3') + headers.forEach((header) => { + header.tabIndex = 0 + header.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + header.click() + } + }) + }) + } +} + +/** + * Makes elevation control panel tabbable and keyboard clickable + */ +function makeElevationControlTabbable() { + // Make activate elevation control button tabbable and keyboard clickable + const activateButton = document.getElementById('btnDoElevationControl') + if (activateButton) { + activateButton.tabIndex = 0 + activateButton.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + activateButton.click() + activateButton.focus() + } + }) + } + + // Make gradient clamp, repeat and mirrored repeat buttons tabbable and keyboard clickable + const gradientButtons = document.querySelectorAll( + '#gradient_repeat_option label' + ) + gradientButtons.forEach((label) => { + label.tabIndex = 0 + label.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + label.click() + } + }) + }) + + // Make gradient schemes tabbable and keyboard clickable + const gradientSpans = document.querySelectorAll( + '#elevation_gradient_scheme_selection span' + ) + gradientSpans.forEach((span) => { + span.tabIndex = 0 + span.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + span.click() + } + }) + }) +} + +/** + * Makes accepted filtering panel tabbable and keyboard clickable + */ +function makeAcceptedFilteringTabbable() { + const activateButton = document.getElementById('doAcceptedFiltering') + if (activateButton) { + activateButton.tabIndex = 0 + activateButton.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + activateButton.click() + activateButton.focus() + } + }) + } +} + +/** + * Makes appearance panel tabbable and keyboard clickable + */ +function makeAppearancePanelTabbable() { + // Make EDL checkbox tabbable and keyboard clickable + const edlCheckbox = document.getElementById('chkEDLEnabled') + if (edlCheckbox) { + edlCheckbox.tabIndex = 0 + edlCheckbox.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + edlCheckbox.click() + } + }) + } + + // Make background buttons tabbable and keyboard clickable + const backgroundButtons = document.querySelectorAll( + '#background_options label' + ) + backgroundButtons.forEach((label) => { + label.tabIndex = 0 + label.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + label.click() + } + }) + }) + + // Make splat quality buttons tabbable and keyboard clickable + const splatQualityButtons = document.querySelectorAll( + '#splat_quality_options label' + ) + splatQualityButtons.forEach((label) => { + label.tabIndex = 0 + label.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + label.click() + } + }) + }) + + // Make box checkbox tabbable and keyboard clickable + const boxCheckbox = document.getElementById('show_bounding_box') + if (boxCheckbox) { + boxCheckbox.tabIndex = 0 + boxCheckbox.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + boxCheckbox.click() + } + }) + } + + // Make lock view checkbox tabbable and keyboard clickable + const lockViewCheckBox = document.getElementById('set_freeze') + if (lockViewCheckBox) { + lockViewCheckBox.tabIndex = 0 + lockViewCheckBox.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + lockViewCheckBox.click() + } + }) + } +} + +/** + * Makes tools panel tabbable and keyboard clickable + */ +function makeToolsPanelTabbable() { + // Make clipping tools tabbable and keyboard clickable + const clippingTools = document.querySelectorAll('#clipping_tools img') + clippingTools.forEach((img, index) => { + // Hide unsupported tool + if (index === 2) { + img.hidden = true + return + } + img.tabIndex = 0 + img.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + img.click() + } + }) + }) + + // Make clip task buttons tabbable and keyboard clickable + const clipTaskButtons = document.querySelectorAll('#cliptask_options label') + clipTaskButtons.forEach((label) => { + label.tabIndex = 0 + label.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + label.click() + } + }) + }) + + // Make clip method buttons tabbable and keyboard clickable + const clipMethodButtons = document.querySelectorAll( + '#clipmethod_options label' + ) + clipMethodButtons.forEach((label) => { + label.tabIndex = 0 + label.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + label.click() + } + }) + }) + + // Hide camera projection options as they are not supported + const cameraProjection = document.getElementById('camera_projection_options') + if (cameraProjection) cameraProjection.hidden = true + + // Make navigation tools tabbable and keyboard clickable + const navigationTools = document.querySelectorAll('#navigation img') + const renderArea = document.querySelector('#potree_render_area') + // Keep wrapper focusable as a fallback target (doesn't change tab order elsewhere) + if (renderArea && !renderArea.hasAttribute('tabindex')) { + renderArea.setAttribute('tabindex', '0') + } + + navigationTools.forEach((img) => { + img.tabIndex = 0 + img.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + img.click() + + // Switch controls directly via Potree API, then focus the keyboard input target (canvas) + const pv = window.potreeViewer || window.viewer || null + const target = renderArea + if (pv) { + const src = (img.getAttribute && img.getAttribute('src')) || '' + const title = + img.getAttribute && + (img.getAttribute('title') || img.getAttribute('data-i18n') || '') + const key = (src + ' ' + title).toLowerCase() + + if (key.includes('earth')) { + pv.setControls(pv.earthControls) + } else if (key.includes('heli') || key.includes('helicopter')) { + pv.setControls(pv.fpControls) + if (pv.fpControls) pv.fpControls.lockElevation = true + } else if ( + key.includes('fps') || + key.includes('flight') || + key.includes('flight_control') + ) { + pv.setControls(pv.fpControls) + if (pv.fpControls) pv.fpControls.lockElevation = false + } else if (key.includes('orbit')) { + pv.setControls(pv.orbitControls) + } + + const dom = pv && pv.renderer && pv.renderer.domElement + if (dom) { + if (!dom.hasAttribute('tabindex')) + dom.setAttribute('tabindex', '-1') + dom.focus() + } else if (target) { + // conservative fallback + if (!target.hasAttribute('tabindex')) + target.setAttribute('tabindex', '0') + target.focus() + } + } else if (target) { + if (!target.hasAttribute('tabindex')) + target.setAttribute('tabindex', '0') + target.focus() + } + } + }) + }) +} + +function makeMeasurementsTabbable() { + // Select every tool inside the measurement tools host + const tools = document.querySelectorAll( + '#measurement_tools_host .tool-with-label' + ) + tools.forEach((tool) => { + tool.tabIndex = 0 + tool.setAttribute('role', 'button') + tool.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + tool.click() + } + }) + }) +} + +/** + * Makes the Scene > Objects tree tabbable and keyboard operable + * - Tab to anchors + * - Enter/Space = select (click) + * - ArrowRight = open node, ArrowLeft = close node (uses jsTree if available) + * - ArrowUp/ArrowDown = move focus to previous/next visible item + * - Home/End = first/last visible item + */ +function makeObjectsTabbable() { + const install = (tree) => { + if (!tree) return + + // Ensure all anchors are tabbable + const setTabbable = (root) => { + root.querySelectorAll('a.jstree-anchor').forEach((a) => { + a.tabIndex = 0 + }) + } + setTabbable(tree) + + // Keep anchors tabbable when jsTree updates DOM + const mo = new MutationObserver((muts) => { + for (const m of muts) { + if (m.addedNodes && m.addedNodes.length) { + m.addedNodes.forEach((n) => { + if (n.nodeType === 1) setTabbable(n) + }) + } + } + }) + mo.observe(tree, { childList: true, subtree: true }) + + const getAnchors = () => + Array.from(tree.querySelectorAll('a.jstree-anchor')) + const focusMove = (from, dir) => { + const anchors = getAnchors() + const i = anchors.indexOf(from) + const next = anchors[i + dir] + if (next) next.focus() + } + const focusEdge = (toLast) => { + const anchors = getAnchors() + const target = toLast ? anchors[anchors.length - 1] : anchors[0] + if (target) target.focus() + } + + // Delegate key handling on anchors + tree.addEventListener('keydown', (e) => { + const a = e.target + if ( + !a || + !(a instanceof HTMLElement) || + !a.classList.contains('jstree-anchor') + ) + return + const li = a.closest('li') + const id = li && li.id + const $ = window.jQuery || window.$ + const inst = $ && $(tree).jstree ? $(tree).jstree(true) : null + + switch (e.key) { + case 'Enter': + case ' ': // space + e.preventDefault() + a.click() + break + case 'ArrowDown': + e.preventDefault() + focusMove(a, +1) + break + case 'ArrowUp': + e.preventDefault() + focusMove(a, -1) + break + case 'Home': + e.preventDefault() + focusEdge(false) + break + case 'End': + e.preventDefault() + focusEdge(true) + break + case 'ArrowRight': + e.preventDefault() + if (inst && id) { + inst.open_node(id) + } else if (li && li.classList.contains('jstree-closed')) { + const toggle = li.querySelector('.jstree-ocl') + if (toggle) + toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } + break + case 'ArrowLeft': + e.preventDefault() + if (inst && id) { + inst.close_node(id) + } else if (li && li.classList.contains('jstree-open')) { + const toggle = li.querySelector('.jstree-ocl') + if (toggle) + toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } + break + } + }) + } + + const tryInit = () => { + const tree = document.getElementById('scene_objects') + if (tree) { + install(tree) + return true + } + return false + } + + if (!tryInit()) { + // Wait for GUI to load + const obs = new MutationObserver(() => { + if (tryInit()) obs.disconnect() + }) + obs.observe(document.body, { childList: true, subtree: true }) + } +} + +/** + * Make dropdowns in Properties tabbable: focus the associated input/select when label is activated via keyboard. + */ +function makeObjectsDropdownsTabbable() { + const make = () => { + const container = document.getElementById('potree_menu') + if (!container) return false + + // Labels that point to inputs/selects + container.querySelectorAll('label[for]').forEach((lbl) => { + lbl.tabIndex = 0 + lbl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + const id = lbl.getAttribute('for') + const target = id && document.getElementById(id) + if (target instanceof HTMLElement) target.focus() + } + }) + }) + + // Ensure selects are in tab order (usually default) and openable via keyboard focus + container.querySelectorAll('select').forEach((sel) => { + sel.tabIndex = 0 + }) + return true + } + + if (!make()) { + const obs = new MutationObserver(() => { + if (make()) obs.disconnect() + }) + obs.observe(document.body, { childList: true, subtree: true }) + } +} diff --git a/src/AnnotationControl/annotationPanel.css b/src/AnnotationControl/annotationPanel.css index 13a3b2e..f250dba 100644 --- a/src/AnnotationControl/annotationPanel.css +++ b/src/AnnotationControl/annotationPanel.css @@ -27,8 +27,8 @@ img.button-icon[src$='/annotation.svg'] { padding: 8px; border-radius: 4px; border: 1px solid #404a50; - background: #2f383d; - color: #cfd5d8; + background: #636262; + color: #636262; font-family: inherit; font-size: 12px; line-height: 1.3; @@ -44,10 +44,6 @@ img.button-icon[src$='/annotation.svg'] { margin-top: 6px; } -.annotation-add-button { - margin: 10px 0; -} - .annotation-empty { opacity: 0.6; padding: 10px; @@ -350,37 +346,68 @@ img.button-icon[src$='/annotation.svg'] { flex: 0 0 18px; } +.pv-menu-list_annotations-panel { + display: flex; + flex-direction: column; + align-items: center; + overflow: hidden; +} + /* Add button */ .annotation-add-button { - background: linear-gradient(180deg, #f6f6f6 0%, #e9e9e9 100%); - color: #222; - padding: 8px 16px; - min-width: 140px; - height: 38px; display: block; - margin: 12px auto; - border-radius: 6px; + width: 80%; + margin: 20px 0 10px; + padding: 10px 10px; font-size: 13px; - font-weight: 700; - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.6) inset; - border: 1px solid #cfcfcf; + font-weight: 500; + background-color: #636262; + color: #ffffff; + border: 1px solid #555; + border-radius: 4px; cursor: pointer; - text-align: center; -} -.annotation-add-button .add-label { - color: #222; - font-weight: 700; + transition: + background-color 0.2s ease, + transform 0.1s ease; } + .annotation-add-button:hover { - background: linear-gradient(180deg, #f3f3f3 0%, #e2e2e2 100%); - border-color: #bfbfbf; + background-color: #8f8f8f; } -.annotation-add-button:active { - transform: translateY(1px); - background: linear-gradient(180deg, #e9e9e9 0%, #dbdbdb 100%); - box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.06); + +#labelToggleContainer { + margin: 8px 0 6px; + padding-left: 4px; } -.annotation-add-button:focus { - outline: 2px solid rgba(100, 100, 100, 0.12); - outline-offset: 2px; +#labelToggleContainer .labels-legend { + font-size: 13px; + color: #ddd; + margin-bottom: 4px; +} + +.toggle-group { + display: flex; + width: 265px; + border: 1px solid black; + border-radius: 4px; + overflow: hidden; +} +.toggle-group button { + flex: 1; + padding: 6px 15px; + background: #a7a9aa; + color: #3d3c3c; + border: 0; + cursor: pointer; + font-weight: 300; + transition: background 0.2s; +} + +.toggle-group button:not(:last-child) { + border-right: 1px solid #555; +} + +.toggle-group button.active { + background: #c7c9ca; + color: #000; } diff --git a/src/AnnotationControl/annotationPanel.js b/src/AnnotationControl/annotationPanel.js index cffe313..684094d 100644 --- a/src/AnnotationControl/annotationPanel.js +++ b/src/AnnotationControl/annotationPanel.js @@ -31,7 +31,7 @@ export function initAnnotationsPanel(viewer) { header.appendChild(headerSpan) const panel = document.createElement('div') - panel.className = 'pv-menu-list annotations-panel' + panel.className = 'pv-menu-list_annotations-panel' const listContainerDiv = document.createElement('div') listContainerDiv.id = 'annotations_list' @@ -68,6 +68,47 @@ export function initAnnotationsPanel(viewer) { targetContainer = panel.querySelector('#annotations_list') } } + + // --- Add Show/Hide labels toggle group --- + const panelEl = + targetContainer.closest('.pv-menu-list_annotations-panel') || + targetContainer.parentElement + + if (!panelEl.querySelector('#labelToggleContainer')) { + const controls = document.createElement('div') + controls.id = 'labelToggleContainer' + controls.innerHTML = ` +

Show/Hide saved locations

+
+ + +
+ ` + // Insert before list of annotations + panelEl.insertBefore(controls, targetContainer) + + // show/hide all annotations + const setLabelsVisible = (visible) => { + const cont = document.getElementById('potree_annotation_container') + if (cont) cont.style.display = visible ? '' : 'none' + } + + const showBtn = controls.querySelector('#showLabelsBtn') + const hideBtn = controls.querySelector('#hideLabelsBtn') + + showBtn.addEventListener('click', () => { + setLabelsVisible(true) + showBtn.classList.add('active') + hideBtn.classList.remove('active') + }) + + hideBtn.addEventListener('click', () => { + setLabelsVisible(false) + hideBtn.classList.add('active') + showBtn.classList.remove('active') + }) + } + if (!targetContainer) { console.warn( 'Annotations list container not found and dynamic injection failed' @@ -947,6 +988,18 @@ export function initAnnotationsPanel(viewer) { annotationHeader.nextElementSibling.style.display = '' } } + // Ensure annotation labels are shown when starting to add a location. + // The "Show" button will be shown as active and reveals Potree's annotation container. + try { + const showBtn = document.getElementById('showLabelsBtn') + const hideBtn = document.getElementById('hideLabelsBtn') + if (showBtn) showBtn.classList.add('active') + if (hideBtn) hideBtn.classList.remove('active') + const cont = document.getElementById('potree_annotation_container') + if (cont) cont.style.display = '' + } catch (e) { + console.warn('Could not enable annotation labels on add', e) + } // Capture current camera view (position) at the moment the user clicks Add let camPos = null try { diff --git a/src/AcceptedFiltering/threePanels.css b/src/Filter/filter.css similarity index 71% rename from src/AcceptedFiltering/threePanels.css rename to src/Filter/filter.css index e83b9e6..e3a377d 100644 --- a/src/AcceptedFiltering/threePanels.css +++ b/src/Filter/filter.css @@ -1,12 +1,10 @@ /* ---------- Buttons (shared look) ---------- */ /* Reuse your accepted button style for all four */ #btnDoElevationControl, -#doAcceptedFiltering, -#btnTHU, -#btnTVU, -#btnTHUFilter { +#doAcceptedFiltering { display: flex; width: 100%; + justify-content: center; margin: 6px 0 10px; padding: 10px 10px; font-size: 13px; @@ -22,55 +20,34 @@ } #btnDoElevationControl:hover, -#doAcceptedFiltering:hover, -#btnTHU:hover, -#btnTVU:hover, -#btnTHUFilter:hover { +#doAcceptedFiltering:hover { background-color: #8f8f8f; } #btnDoElevationControl:active, -#doAcceptedFiltering:active, -#btnTHU:active, -#btnTVU:active, -#btnTHUFilter:active { +#doAcceptedFiltering:active { transform: scale(0.97); background-color: #a8a6a6; } /* Optional: “active mode” outline if you toggle a class via JS */ #btnDoElevationControl.active, -#doAcceptedFiltering.active, -#btnTHU.active, -#btnTVU.active, -#btnTHUFilter:active { +#doAcceptedFiltering.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'] { +#elevation_list [id='materials.elevation_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 { +#elevation_list, +#accepted_list_host { display: block; padding: 4px 0; } @@ -101,16 +78,14 @@ /* ---------- 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 { +#menu_elevation + .pv-menu-list, +#menu_accepted + .pv-menu-list { padding-top: 6px; } /* Optional: header label color alignment with dark UI */ -#menu_elevation2 span, -#menu_accepted span, -#menu_thu_tvu span { +#menu_elevation span, +#menu_accepted span { color: #e6e6e6; font-weight: 600; letter-spacing: 0.2px; @@ -128,22 +103,32 @@ align-items: center; margin: 3px 0; font-size: 13px; - color: #ddd; /* visible text */ + color: #ddd; } -/* Color boxes */ +/* Color boxes → now circles */ #accepted_legend .legend-color { - width: 16px; - height: 16px; - border: 1px solid #777; - margin-right: 8px; - border-radius: 2px; + width: 20px; + height: 8px; + border-radius: 4px; /* pill shape */ + margin-left: 6px; } #accepted_legend .legend-color.accepted { background-color: #fff; + border: #0008; + box-shadow: 0 0 4px 1px #fff8; } #accepted_legend .legend-color.not-accepted { background-color: #000; + box-shadow: 0 0 4px 1px #fff8; +} + +#gradient_repeat_option fieldset { + margin: 15px 0px 12px 0px !important; +} + +#gradient_repeat_option fieldset legend { + margin: 0px 0px 5px 0px !important; } diff --git a/src/AcceptedFiltering/threePanels.js b/src/Filter/filter.js similarity index 89% rename from src/AcceptedFiltering/threePanels.js rename to src/Filter/filter.js index 249df0f..5fd6b74 100644 --- a/src/AcceptedFiltering/threePanels.js +++ b/src/Filter/filter.js @@ -1,7 +1,10 @@ -// 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) - +/** Two Potree sidebar sections with buttons + panel bodies: + * + * • Elevation → moves #materials.elevation_container into our Elevation body + * Used for controlling the elevation gradient with a slider and altering between different gradient schemes + * • Accepted → custom UI fully defined here (no external module) + * Used for indicating which points/surveys are accepted as the seabed and which are not + */ const byId = (id) => document.getElementById(id) /** @@ -88,7 +91,7 @@ function ensurePanelScaffold(listId) { * @param {'elevation'|'accepted'} key */ function showOnly(key) { - const elevBody = byId('elevation2_list')?.querySelector('.panel-body') + const elevBody = byId('elevation_list')?.querySelector('.panel-body') const accBody = byId('accepted_list_host')?.querySelector('.panel-body') if (elevBody) elevBody.style.display = key === 'elevation' ? '' : 'none' @@ -150,7 +153,7 @@ function selectCloudNode(hooks) { * @param {number} pollEvery * @returns {Promise} */ -async function waitForOrPoll(id, softMs = 1400, pollEvery = 120) { +async function waitForOrPoll(id, softMs = 1400, pollEvery = 10) { const start = performance.now() while (performance.now() - start < softMs) { const el = byId(id) @@ -168,9 +171,9 @@ function createElevationPanel() { insertSection({ headerId: 'menu_elevation', headerText: 'Elevation Control', - listId: 'elevation2_list' + listId: 'elevation_list' }) - ensurePanelScaffold('elevation2_list') + ensurePanelScaffold('elevation_list') } /** @@ -178,7 +181,7 @@ function createElevationPanel() { * @param {{onActivateElevation?:Function}} hooks */ function ensureElevationButton(hooks) { - const { btns } = ensurePanelScaffold('elevation2_list') + const { btns } = ensurePanelScaffold('elevation_list') if (!btns || byId('btnDoElevationControl')) return const btn = document.createElement('button') @@ -186,6 +189,10 @@ function ensureElevationButton(hooks) { btn.type = 'button' btn.textContent = 'Activate elevation control' btn.addEventListener('click', () => { + const elevBtn = byId('btnDoElevationControl') + const accBtn = byId('doAcceptedFiltering') + if (elevBtn) elevBtn.style.display = 'none' + if (accBtn) accBtn.style.display = '' switchMode('elevation', hooks?.onActivateElevation, hooks) }) btns.appendChild(btn) @@ -204,11 +211,11 @@ function setUpElevationSlider(hooks) { const update = () => { const low = slider.slider('values', 0) const high = slider.slider('values', 1) - label.textContent = `${low.toFixed(2)} to ${high.toFixed(2)}` + label.textContent = `${Math.round(low)} to ${Math.round(high)}` hooks?.onElevationRangeChange([low, high]) } - slider.slider({ min: -10000, max: 0, values: [-10000, 0] }) + slider.slider({ min: -10000, max: 0, values: [-10000, 0], step: 1 }) slider.off('slide.custom slidestop.custom change.custom') slider.on('slide.custom slidestop.custom change.custom', update) update() @@ -219,7 +226,7 @@ function setUpElevationSlider(hooks) { * @returns {boolean} true if moved or already in place */ function moveElevationContainer(hooks) { - const { body } = ensurePanelScaffold('elevation2_list') + const { body } = ensurePanelScaffold('elevation_list') const src = byId('materials.elevation_container') if (!body || !src) return false @@ -228,6 +235,7 @@ function moveElevationContainer(hooks) { setUpElevationSlider(hooks) accordionRefresh() } + src.style.removeProperty('display') return true } @@ -279,6 +287,10 @@ function ensureAcceptedButton(hooks) { btn.type = 'button' btn.textContent = 'Activate accepted filter' btn.addEventListener('click', () => { + const accBtn = byId('doAcceptedFiltering') + const elevBtn = byId('btnDoElevationControl') + if (accBtn) accBtn.style.display = 'none' + if (elevBtn) elevBtn.style.display = '' switchMode('accepted', hooks?.onActivateAccepted, hooks) }) btns.appendChild(btn) @@ -299,12 +311,12 @@ function ensureAcceptedLegend() { legend.style.display = 'none' legend.innerHTML = `
+ Accepted points displayed as:
- Accepted points
+ Not accepted points displayed as:
- Not accepted points
` list.appendChild(legend) @@ -412,7 +424,7 @@ function attachSelfHealing(activeGetter, hooks) { const mode = activeGetter() if (mode === 'elevation') { const src = byId('materials.elevation_container') - const { body } = ensurePanelScaffold('elevation2_list') + const { body } = ensurePanelScaffold('elevation_list') if (src && body && src.parentNode !== body) moveElevationContainer(hooks) } }) @@ -426,7 +438,7 @@ function attachSelfHealing(activeGetter, hooks) { * @param {object} viewer Potree viewer (not used directly here but available to hooks) * @param {{onActivateElevation?:Function, onActivateAccepted?:Function, selectCloudOnce?:Function, onElevationRangeChange?:Function}} hooks */ -export function initThreePanels(viewer, hooks = {}) { +export function initFilterPanels(viewer, hooks = {}) { // Build sections initElevationControls(hooks) initAcceptedControlsInline(hooks) diff --git a/src/MeasurementControl/measurementsPanel.css b/src/MeasurementControl/measurementsPanel.css index 96f735f..eba5091 100644 --- a/src/MeasurementControl/measurementsPanel.css +++ b/src/MeasurementControl/measurementsPanel.css @@ -38,9 +38,14 @@ #measurements_list td { padding: 2px 4px; } -#measurements_list tr:nth-child(even) { +#measurements_list table.measurement_value_table tr.alt-even td { background: #3a454b; } + +/* Fallback: ensure odd rows are neutral unless other row-type rules apply */ +#measurements_list table.measurement_value_table tr.alt-odd td { + background: transparent; +} #measurements_list .coordRow td { background: #2f383d; font-family: monospace; @@ -271,3 +276,60 @@ border-top: 1px solid #303a3f; padding-top: 10px; } + +.tool-with-label { + display: flex; + flex-direction: row; + align-items: center; + margin-top: 10px; + cursor: pointer; + border-radius: 4px; + padding: 4px; +} +.tool-with-label:hover { + box-shadow: 0 0 5px #fff8; +} + +.tool-with-label:hover img { + filter: brightness(1.7); +} + +.tool-with-label:hover .tool-label { + color: #fff; +} + +.tool-label { + font-size: 14px; + margin-top: 2px; + margin-left: 4px; + color: #aaa; + pointer-events: none; +} + +/* On-canvas measurement label styling for improved contrast */ +.measurement-canvas-label { + position: absolute; + transform: translate(-50%, -100%); + pointer-events: none; + color: #000; + background: rgba(255, 255, 255, 0.95); + padding: 2px 6px; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); + border: 1px solid rgba(0, 0, 0, 0.08); + font-weight: 600; + font-size: 12px; + white-space: nowrap; + z-index: 2100; +} + +/* Container overlay for on-canvas measurement labels. */ +.measurement-label-overlay { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 2000; +} diff --git a/src/MeasurementControl/measurementsPanel.js b/src/MeasurementControl/measurementsPanel.js index 6b70ac2..5be335e 100644 --- a/src/MeasurementControl/measurementsPanel.js +++ b/src/MeasurementControl/measurementsPanel.js @@ -1,3 +1,5 @@ +import { ecef, wgs84 } from '../config.js' + /** * Measurements Panel * Injects a custom Measurements tab section, shows grouped measurement @@ -6,7 +8,7 @@ */ export function initMeasurementsPanel(viewer) { // Track last selected measurement label for dynamic data title - const lastSelection = { uuid: null, label: '' } + const lastSelection = { uuid: null, label: '', type: null } // Resolve or create measurements container in Potree menu const existingListContainer = document.getElementById('measurements_list') let targetContainer = existingListContainer @@ -29,7 +31,7 @@ export function initMeasurementsPanel(viewer) { panel.appendChild(toolsHostDiv) panel.appendChild(listContainerDiv) // Insert before filters/tools if possible, else append at end - const tools = document.getElementById('menu_tools') + const tools = document.getElementById('menu_appearance') if (tools) { menu.insertBefore(panel, tools) menu.insertBefore(header, panel) @@ -64,6 +66,18 @@ export function initMeasurementsPanel(viewer) { let listRoot = document.createElement('div') listRoot.id = 'measurement_items' listRoot.className = 'measurement-items-root' + // Overlay container for on-canvas measurement labels (Point #N) + const renderArea = document.getElementById('potree_render_area') + let overlay = null + const overlayMap = new Map() // uuid -> label element + // set default display mode of the label buttons + let measurementDisplayMode = 'VALUES' + if (renderArea) { + overlay = document.createElement('div') + overlay.id = 'measurement_label_overlay' + overlay.classList.add('measurement-label-overlay') + renderArea.appendChild(overlay) + } const listDivider = document.createElement('div') listDivider.className = 'divider' const dividerSpan = document.createElement('span') @@ -101,6 +115,128 @@ export function initMeasurementsPanel(viewer) { return indexMap } + // Project a THREE.Vector3 (or [x,y,z]) into screen coords using viewer. Used later to display labels. + function projectToScreen(pos) { + try { + const THREE = window.THREE || globalThis.THREE + let vec3 = null + if (!pos) return null + if (Array.isArray(pos) && pos.length >= 3) + vec3 = new THREE.Vector3(pos[0], pos[1], pos[2]) + else if (pos.isVector3) vec3 = pos.clone() + else if (pos.position && pos.position.isVector3) + vec3 = pos.position.clone() + else if ( + pos.x !== undefined && + pos.y !== undefined && + pos.z !== undefined + ) + vec3 = new THREE.Vector3(pos.x, pos.y, pos.z) + if (!vec3) return null + // choose camera + const cam = + viewer.scene && typeof viewer.scene.getActiveCamera === 'function' + ? viewer.scene.getActiveCamera() + : (viewer.scene && viewer.scene.camera) || + viewer.scene.cameraP || + null + if (!cam) return null + vec3.project(cam) + // renderer canvas + const canvas = + (viewer && viewer.renderer && viewer.renderer.domElement) || + document.querySelector('#potree_render_area canvas') + if (!canvas) return null + const w = canvas.clientWidth || canvas.width + const h = canvas.clientHeight || canvas.height + const x = (vec3.x * 0.5 + 0.5) * w + const y = (-vec3.y * 0.5 + 0.5) * h + // check if behind camera + const visible = vec3.z < 1 + return { x, y, visible } + } catch (e) { + return null + } + } + + // Return a representative 3D position for a measurement-like object. + // For single-point measurements we return that point; for multi-point + // measurements we return the centroid of all point positions. + function getMeasurementRepresentativePosition(o) { + try { + const THREE = window.THREE || globalThis.THREE + if (!o || !o.points || o.points.length === 0) return null + if (o.points.length === 1) { + return o.points[0].position || o.points[0] + } + const v = new THREE.Vector3(0, 0, 0) + let count = 0 + for (const pt of o.points) { + const p = pt.position || pt + if (!p) continue + v.x += p.x + v.y += p.y + v.z += p.z + count++ + } + if (count === 0) return null + v.x /= count + v.y /= count + v.z /= count + return v + } catch (e) { + return null + } + } + + // Create or update an on-canvas label for a measurement object + function createOrUpdateMeasurementCanvasLabel(measurement, labelText) { + if (!overlay || !measurement || !measurement.uuid) return null + let lbl = overlayMap.get(measurement.uuid) + if (!lbl) { + lbl = document.createElement('div') + lbl.className = 'measurement-canvas-label' + overlay.appendChild(lbl) + overlayMap.set(measurement.uuid, lbl) + } + lbl.textContent = labelText || '' + try { + // only show overlay name labels when in NAMES mode + lbl.style.display = measurementDisplayMode === 'NAMES' ? '' : 'none' + } catch (e) {} + return lbl + } + + // Recompute screen positions for all overlay labels and update their + // left/top styles. Hide labels when the measurement or position is not visible. + function updateOverlayPositions() { + if (!overlay) return + for (const [uuid, el] of overlayMap.entries()) { + try { + const scene = viewer.scene + const all = [...scene.measurements, ...scene.profiles, ...scene.volumes] + const obj = all.find((o) => o.uuid === uuid) + if (!obj) { + el.style.display = 'none' + continue + } + // Get a representative position (centroid or first point) + const rep = getMeasurementRepresentativePosition(obj) + const pos = projectToScreen(rep) + if (!pos || !pos.visible) { + el.style.display = 'none' + } else { + // Only show overlay name labels when NAMES mode is active + el.style.display = measurementDisplayMode === 'NAMES' ? '' : 'none' + el.style.left = Math.round(pos.x) + 'px' + el.style.top = Math.round(pos.y) + 'px' + } + } catch (e) { + // ignore + } + } + } + const TYPE_ICONS = { Point: '●', Distance: '﹔', @@ -137,7 +273,9 @@ export function initMeasurementsPanel(viewer) { 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 + // 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() { @@ -196,8 +334,19 @@ export function initMeasurementsPanel(viewer) { } } } + // Set or remove the mounted marker to run only for + // single-point measurements. + try { + if (lastSelection && lastSelection.type === 'Point') { + originalPropertiesPanel.setAttribute('data-mp-mounted', '1') + } else { + originalPropertiesPanel.removeAttribute('data-mp-mounted') + } + } catch (e) {} requestAnimationFrame(() => { roundCoordinates(originalPropertiesPanel) + insertLatLonRows(originalPropertiesPanel) + pruneMeasurementRows(originalPropertiesPanel) initCoordObserver() }) } @@ -208,6 +357,9 @@ export function initMeasurementsPanel(viewer) { originalPropertiesPanel, placeholder.nextSibling ) + try { + originalPropertiesPanel.removeAttribute('data-mp-mounted') + } catch (e) {} } if (targetContainer && targetContainer.children.length === 0) { targetContainer.innerHTML = '' @@ -231,7 +383,18 @@ export function initMeasurementsPanel(viewer) { function initCoordObserver() { if (!originalPropertiesPanel || coordRoundObserver) return coordRoundObserver = new MutationObserver(() => { - requestAnimationFrame(() => roundCoordinates(originalPropertiesPanel)) + requestAnimationFrame(() => { + // Only run post-processing when the properties panel is mounted into + // our measurements area. + if (!originalPropertiesPanel) return + const mounted = + originalPropertiesPanel.getAttribute && + originalPropertiesPanel.getAttribute('data-mp-mounted') === '1' + if (!mounted) return + roundCoordinates(originalPropertiesPanel) + pruneMeasurementRows(originalPropertiesPanel) + insertLatLonRows(originalPropertiesPanel) + }) }) coordRoundObserver.observe(originalPropertiesPanel, { childList: true, @@ -240,25 +403,54 @@ export function initMeasurementsPanel(viewer) { } function roundCoordinates(rootEl) { + if (!rootEl) rootEl = originalPropertiesPanel if (!rootEl) return + // Only run post-processing when the properties panel is mounted into + // our measurements area. + const mounted = + originalPropertiesPanel && + originalPropertiesPanel.getAttribute && + originalPropertiesPanel.getAttribute('data-mp-mounted') === '1' + if (!mounted) 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 + const looksLikeNumber = (s) => { + if (!s) return false + const cleaned = (s + '').replace(/[^0-9+\-.,eE]/g, '').replace(/,+/g, '') + return /[-+]?\d*\.?\d+(e[-+]?\d+)?/.test(cleaned) + } 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 + let found = false + if (headerRow) { + 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 + } + } + // Fallback: detect a row with 3 numeric td cells (some panels use td headers) + const rows = Array.from(tbl.querySelectorAll('tr')) + for (const r of rows) { + const tds = Array.from(r.querySelectorAll('td')) + if ( + tds.length >= 3 && + tds.every((td) => looksLikeNumber(td.textContent)) + ) { + coordTable = tbl + found = true + break + } } + if (found) break } if (!coordTable) return @@ -283,6 +475,276 @@ export function initMeasurementsPanel(viewer) { }) }) } + + // Hide unwanted measurement attribute rows (keep only relevant ones) + function pruneMeasurementRows(rootEl) { + if (!rootEl) rootEl = originalPropertiesPanel + if (!rootEl) return + // Only run post-processing when the properties panel is mounted into + // our measurements area. + const mounted = + originalPropertiesPanel && + originalPropertiesPanel.getAttribute && + originalPropertiesPanel.getAttribute('data-mp-mounted') === '1' + if (!mounted) return + const tables = rootEl.querySelectorAll('table.measurement_value_table') + if (!tables || tables.length === 0) return + // Labels we want to keep + const keep = new Set([ + 'point source id', + 'accepted', + 'tvu', + 'thu', + 'latitude', + 'longitude', + 'elevation' + ]) + tables.forEach((tbl) => { + // Detect if this table is the coordinates table (header with th: x y z) + const headerRow = tbl.querySelector('tr') + let isCoordTable = false + const looksLikeNumber = (s) => { + if (!s) return false + const cleaned = (s + '') + .replace(/[^0-9+\-.,eE]/g, '') + .replace(/,+/g, '') + return /[-+]?\d*\.?\d+(e[-+]?\d+)?/.test(cleaned) + } + if (headerRow) { + const ths = [...headerRow.querySelectorAll('th')].map((th) => + (th.textContent || '').trim().toLowerCase() + ) + if ( + ths.length >= 3 && + ths[0] === 'x' && + ths[1] === 'y' && + ths[2] === 'z' + ) { + isCoordTable = true + } + } + if (!isCoordTable) { + const rows = Array.from(tbl.querySelectorAll('tr')) + for (const r of rows) { + const tds = Array.from(r.querySelectorAll('td')) + if ( + tds.length >= 3 && + tds.every((td) => looksLikeNumber(td.textContent)) + ) { + isCoordTable = true + break + } + } + } + + ;[...tbl.querySelectorAll('tr')].forEach((row) => { + const tds = [...row.querySelectorAll('td')] + // If this is the coordinates table, skip pruning rows that look like + // coordinate rows (they have 3 or more td columns). + if (isCoordTable && tds.length >= 3) return + const firstTd = tds[0] + if (!firstTd) return + const txt = (firstTd.textContent || '').trim().toLowerCase() + if (!keep.has(txt)) { + row.style.display = 'none' + } else { + row.style.display = '' + } + }) + // After hiding rows, reapply alternating classes to visible rows so + // zebra striping remains correct even when some rows are display:none + const visibleRows = [...tbl.querySelectorAll('tr')].filter( + (r) => r.style.display !== 'none' + ) + visibleRows.forEach((r, i) => { + r.classList.remove('alt-even', 'alt-odd') + r.classList.add(i % 2 === 0 ? 'alt-odd' : 'alt-even') + }) + }) + } + + // Insert Latitude / Longitude / Elevation rows into the attribute table + function insertLatLonRows(rootEl) { + if (!rootEl) rootEl = originalPropertiesPanel + if (!rootEl) return + // Only insert Lat/Lon/Elev when the properties panel is explicitly mounted + const mounted = + originalPropertiesPanel && + originalPropertiesPanel.getAttribute && + originalPropertiesPanel.getAttribute('data-mp-mounted') === '1' + if (!mounted) return + const tables = Array.from( + rootEl.querySelectorAll('table.measurement_value_table') + ) + if (!tables.length) return + + // helper to parse numeric strings + const parseNum = (s) => { + if (!s) return null + const cleaned = (s + '').replace(/[^0-9+\-.,]/g, '').replace(/,+/g, '') + return /[-+]?\d*\.?\d+/.test(cleaned) ? Number(cleaned) : null + } + + // Find coord table (header x,y,z) and first data row + let coordTable = null + let coordinateRow = null + for (const tbl of tables) { + const header = tbl.querySelector('tr') + if (!header) continue + const ths = [...header.querySelectorAll('th')].map((t) => + (t.textContent || '').trim().toLowerCase() + ) + if ( + ths.length >= 3 && + ths[0] === 'x' && + ths[1] === 'y' && + ths[2] === 'z' + ) { + coordTable = tbl + const rows = Array.from(tbl.querySelectorAll('tr')) + for (let i = 0; i < rows.length; i++) { + if (rows[i] === header) { + for (let j = i + 1; j < rows.length; j++) { + const tds = Array.from(rows[j].querySelectorAll('td')) + if (tds.length >= 3) { + const nx = parseNum(tds[0].textContent) + const ny = parseNum(tds[1].textContent) + const nz = parseNum(tds[2].textContent) + if (nx != null && ny != null && nz != null) { + coordinateRow = rows[j] + break + } + } + } + break + } + } + break + } + // Fallback: look for a row with 3 numeric tds anywhere in the table + const rows = Array.from(tbl.querySelectorAll('tr')) + for (let j = 0; j < rows.length; j++) { + const tds = Array.from(rows[j].querySelectorAll('td')) + if (tds.length >= 3) { + const nx = parseNum(tds[0].textContent) + const ny = parseNum(tds[1].textContent) + const nz = parseNum(tds[2].textContent) + if (nx != null && ny != null && nz != null) { + coordTable = tbl + coordinateRow = rows[j] + break + } + } + } + if (coordinateRow) break + } + + let x = null, + y = null, + z = null + if (coordinateRow) { + const tds = Array.from(coordinateRow.querySelectorAll('td')) + x = parseNum(tds[0].textContent) + y = parseNum(tds[1].textContent) + z = parseNum(tds[2].textContent) + } + + // compute lat/lon/elev + let lat = null, + lon = null, + elev = null + const hasProj4 = + typeof proj4 !== 'undefined' || + (typeof window !== 'undefined' && typeof window.proj4 !== 'undefined') + const proj = + typeof proj4 !== 'undefined' + ? proj4 + : typeof window !== 'undefined' + ? window.proj4 + : undefined + if ( + hasProj4 && + typeof ecef !== 'undefined' && + typeof wgs84 !== 'undefined' && + x != null + ) { + try { + const res = proj(ecef, wgs84, [x, y, z]) + lon = res[0] + lat = res[1] + elev = res[2] + } catch (_e) {} + } + + // find first attribute-style table + let attrTable = + tables.find((t) => + Array.from(t.querySelectorAll('tr')).some( + (r) => r.querySelectorAll('td').length === 2 + ) + ) || + coordTable || + tables[0] + + const ids = { + lat: 'mp_coord_latitude', + lon: 'mp_coord_longitude', + elev: 'mp_coord_elevation' + } + + const fmt = (v, decimals) => (v == null ? '' : Number(v).toFixed(decimals)) + const latStr = lat != null ? fmt(lat, 4) + '˚' : '' + const lonStr = lon != null ? fmt(lon, 4) + '˚' : '' + const elevStr = elev != null ? fmt(elev, 4) + 'm' : '' + + // Prepare rows (create new ones and update existing ones). We collect + // newly-created rows and insert them as a fragment before the first + // child so they appear at the top in the desired order. + const createRowNode = (id, label, value) => { + const row = document.createElement('tr') + row.id = id + row.className = 'attr-row' + const tdLabel = document.createElement('td') + tdLabel.className = 'property-name' + tdLabel.textContent = label + const tdValue = document.createElement('td') + tdValue.className = 'property-value' + tdValue.textContent = value + row.appendChild(tdLabel) + row.appendChild(tdValue) + return row + } + + const newRows = [] + const entries = [ + { id: ids.lat, label: 'Latitude', value: latStr }, + { id: ids.lon, label: 'Longitude', value: lonStr }, + { id: ids.elev, label: 'Elevation', value: elevStr } + ] + + for (const e of entries) { + const existing = rootEl.querySelector(`#${e.id}`) + if (existing) { + const valTd = + existing.querySelector('td:last-child') || + existing.querySelectorAll('td')[1] + if (valTd) valTd.textContent = e.value + } else { + newRows.push(createRowNode(e.id, e.label, e.value)) + } + } + + if (newRows.length > 0) { + const tbody = + attrTable.tBodies && attrTable.tBodies[0] + ? attrTable.tBodies[0] + : attrTable + const first = tbody.firstElementChild + const frag = document.createDocumentFragment() + for (const r of newRows) frag.appendChild(r) + tbody.insertBefore(frag, first) + } + } // Helper to decide if a uuid is a measurement-like object function isMeasurementUUID(uuid) { if (!uuid) return false @@ -306,10 +768,24 @@ export function initMeasurementsPanel(viewer) { if (labelEl) { lastSelection.uuid = uuid lastSelection.label = labelEl.textContent.trim() + // Also store the measurement type (Point/Profile/Area/Height/etc.) + try { + const scene = viewer.scene + const all = [ + ...scene.measurements, + ...scene.profiles, + ...scene.volumes + ] + const obj = all.find((o) => o.uuid === uuid) + lastSelection.type = obj ? resolveType(obj) : null + } catch (_e) { + lastSelection.type = null + } } } else { lastSelection.uuid = null lastSelection.label = '' + lastSelection.type = null } } @@ -402,6 +878,16 @@ export function initMeasurementsPanel(viewer) { row.appendChild(labelSpan) row.appendChild(delBtn) body.appendChild(row) + + // If this measurement has one or more points, create/update an overlay + // label so the measurement name is visible on the canvas. We use the + // sidebar label so the on-canvas label matches the list. + if (overlay && m.points && m.points.length > 0) { + createOrUpdateMeasurementCanvasLabel( + m, + labelSpan.textContent || baseName + ) + } }) countSpan.textContent = groups.get(type).length }) @@ -429,6 +915,23 @@ export function initMeasurementsPanel(viewer) { showPanelInMeasurements() } } + // Cleanup overlay labels for removed measurements and update positions + try { + // remove overlay labels for uuids that no longer exist + const uuidsWithPoints = new Set( + itemsRaw + .filter((it) => it.obj && it.obj.points && it.obj.points.length > 0) + .map((it) => it.obj.uuid) + ) + for (const k of Array.from(overlayMap.keys())) { + if (!uuidsWithPoints.has(k)) { + const el = overlayMap.get(k) + if (el && el.parentElement) el.parentElement.removeChild(el) + overlayMap.delete(k) + } + } + updateOverlayPositions() + } catch (e) {} } rebuildMeasurementList() @@ -448,6 +951,16 @@ export function initMeasurementsPanel(viewer) { try { obj.addEventListener(ev, () => { rebuildMeasurementList() + // If this object no longer has any points, remove any overlay + // immediately so the on-canvas label doesn't linger. + try { + if (!obj.points || obj.points.length === 0) { + const ol = overlayMap.get(obj.uuid) + if (ol && ol.parentElement) ol.parentElement.removeChild(ol) + overlayMap.delete(obj.uuid) + } + } catch (_e) {} + if (lastSelection.uuid === obj.uuid) { updateActiveSelection(obj.uuid) showPanelInMeasurements() @@ -455,6 +968,24 @@ export function initMeasurementsPanel(viewer) { }) } catch (_e) {} }) + + // Listen to movement events separately so we can update overlay label + // positions immediately when a marker/point is moved + ;['marker_moved', 'marker_dropped', 'point_moved'].forEach((ev) => { + try { + obj.addEventListener(ev, () => { + // schedule a position update on next frame + requestAnimationFrame(() => updateOverlayPositions()) + // If this measurement is currently selected, refresh the panel too + if (lastSelection.uuid === obj.uuid) { + try { + updateActiveSelection(obj.uuid) + showPanelInMeasurements() + } catch (_e) {} + } + }) + } catch (_e) {} + }) } // Distance/Height/Angle measurements can change type based on point count @@ -550,6 +1081,18 @@ export function initMeasurementsPanel(viewer) { } }) + // Update overlay positions when camera moves or window resizes + try { + if (viewer && typeof viewer.addEventListener === 'function') { + viewer.addEventListener('camera_changed', () => + requestAnimationFrame(updateOverlayPositions) + ) + } + } catch (e) {} + window.addEventListener('resize', () => + requestAnimationFrame(updateOverlayPositions) + ) + // Click handling for selection, focus and delete listRoot.addEventListener('click', (e) => { const header = e.target.closest('.m-group-header') @@ -583,6 +1126,13 @@ export function initMeasurementsPanel(viewer) { scene.removeVolume(obj) else if (scene.removeProfile && scene.profiles.includes(obj)) scene.removeProfile(obj) + // Remove any on-canvas overlay immediately for this uuid so the + // label doesn't linger while other async updates occur. + try { + const ol = overlayMap.get(obj.uuid) + if (ol && ol.parentElement) ol.parentElement.removeChild(ol) + overlayMap.delete(obj.uuid) + } catch (_e) {} rebuildMeasurementList() return } @@ -650,10 +1200,75 @@ export function initMeasurementsPanel(viewer) { toolsHost.appendChild(existingTools) } + // After tools are moved into `toolsHost` + const toolDescriptions = { + 'angle.png': 'Measure angle', + 'point.svg': 'Inspect point', + 'distance.svg': 'Measure distance', + 'height.svg': 'Measure height', + 'circle.svg': 'Circle', + 'azimuth.svg': 'Azimuth', + 'area.svg': 'Area', + 'volume.svg': 'Volume', + 'sphere_distances.svg': 'Sphere volume', + 'profile.svg': '2D height profile', + 'reset_tools.svg': 'Remove all' + } + + const toolIcons = existingTools.querySelectorAll('img') + toolIcons.forEach((img) => { + const src = img.getAttribute('src') + const file = src.split('/').pop() // extract icon name + const baseName = file.replace(/\.[^/.]+$/, '') + + if (toolDescriptions[file]) { + const wrapper = document.createElement('div') + wrapper.className = 'tool-with-label' + wrapper.id = `tool-wrapper-${baseName}` + + wrapper.addEventListener('click', () => img.click()) + + img.parentNode.insertBefore(wrapper, img) + wrapper.appendChild(img) + + const label = document.createElement('span') + label.className = 'tool-label' + label.textContent = toolDescriptions[file] + label.id = `label-${file.replace(/\.[^/.]+$/, '')}` + wrapper.appendChild(label) + } + }) + // Move measurement options UI into our tools host if (toolsHost) { const measOptions = document.getElementById('measurement_options_show') if (measOptions) { + // Replace the built two-option control with a three-option label handling + try { + const hasValues = + !!measOptions.querySelector('#measurement_options_show_values') || + !!measOptions.querySelector('option[value="VALUES"]') + if (!hasValues) { + measOptions.innerHTML = `\n + \n + \n + \n + ` + } + + if ( + window && + window.jQuery && + typeof $(measOptions).selectgroup === 'function' + ) { + try { + $(measOptions).selectgroup({ title: 'Show/Hide labels' }) + // trigger the VALUES option so UI appears selected and handlers run + const $val = $(measOptions).find('input[value="VALUES"]') + if ($val.length) $val.trigger('click') + } catch (e) {} + } + } catch (e) {} const measLi = measOptions.closest('li') || measOptions if (measLi && !measLi.classList.contains('measurement-options-block')) { measLi.classList.add('measurement-options-block') @@ -673,6 +1288,66 @@ export function initMeasurementsPanel(viewer) { if (measLi && measLi.parentElement !== toolsHost) { toolsHost.appendChild(measLi) } + // Force initial VALUES mode here + try { + if (window && window.jQuery) { + $(document).trigger('measurement_display_mode', ['VALUES']) + } + // also set Potree measuring tool state directly if present + if (viewer && viewer.measuringTool) { + try { + viewer.measuringTool.showLabels = true // show numeric/value labels + } catch (e) {} + } + } catch (e) {} + } + } + + // Listen for measurement display mode events (VALUES / NAMES / HIDE) + // and toggle the overlay name labels we create in this panel. + try { + const applyDisplayMode = (mode) => { + // remember the chosen mode so new overlays follow it + measurementDisplayMode = mode || measurementDisplayMode + if (!overlay || !overlayMap) return + for (const el of overlayMap.values()) { + if (!el) continue + if (measurementDisplayMode === 'NAMES') { + // show name overlays + el.style.display = '' + } else { + // hide name overlays in both VALUES and HIDE modes + el.style.display = 'none' + } + } + // Also ensure the measuringTool's numeric/value labels follow the mode. + try { + if (viewer && viewer.measuringTool) { + // show numeric/value labels only in VALUES mode + viewer.measuringTool.showLabels = measurementDisplayMode === 'VALUES' + } + } catch (e) {} + } + + // If jQuery triggers the custom event from potree toolbar, handle it + if (window && window.jQuery) { + $(document).on('measurement_display_mode', (e, mode) => { + applyDisplayMode(mode) + }) + } + + // Also attach a direct listener to input clicks inside the control in case + // the selectgroup implementation creates native inputs. + const measEl = document.getElementById('measurement_options_show') + if (measEl) { + measEl.addEventListener('click', (ev) => { + const input = + ev.target && ev.target.closest && ev.target.closest('input') + const value = input ? input.value : null + if (value) applyDisplayMode(value) + }) } + } catch (e) { + // non-fatal if event hooks cannot be installed } } diff --git a/src/cameraSync.js b/src/cameraSync.js index cead992..4b40b76 100644 --- a/src/cameraSync.js +++ b/src/cameraSync.js @@ -58,11 +58,7 @@ export function syncCameras(potreeViewer, cesiumViewer) { } /** - * 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. + * Determines whether the globe should be visible based on the camera position and controls. * * @param cameraPos - The camera position in Cesium.Cartesian3 coordinates * @param pivot - The pivot point in Cesium.Cartesian3 coordinates @@ -99,7 +95,17 @@ function shouldShowGlobe(cameraPos, pivot, direction) { 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 + // Get the camera's elevation based on ellipsoid + const cameraCarto = Cesium.Cartographic.fromCartesian(cameraPos, ellipsoid) + const elevation = cameraCarto.height + + // Determine globe visibility based on camera controls + if (window.potreeViewer.getControls() === window.potreeViewer.orbitControls) { + // 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 + } else { + // If the camera is inside the globe, or looking nearly straight down, hide the globe + return elevation > 0 && dotProduct < 0.99 + } } diff --git a/src/main.js b/src/main.js index 87aaafe..c745661 100644 --- a/src/main.js +++ b/src/main.js @@ -22,6 +22,8 @@ async function init() { POTREE_SETTINGS ) + const potreeViewer = window.potreeViewer + window.viewer = window.potreeViewer potreeViewer.addEventListener('camera_changed', updateText) setupRightClickListener(potreeViewer) diff --git a/src/potreeViewer.js b/src/potreeViewer.js index ad7a0dc..f16ebcd 100644 --- a/src/potreeViewer.js +++ b/src/potreeViewer.js @@ -1,11 +1,10 @@ import { initAnnotationsPanel } from './AnnotationControl/annotationPanel.js' import { initMeasurementsPanel } from './MeasurementControl/measurementsPanel.js' import { initMiniMap } from './MiniMap/miniMap.js' -import { - initThreePanels, - toggleAcceptedLegend -} from './AcceptedFiltering/threePanels.js' +import { makeMenuTabbable } from './Accessibility/makeMenuTabbable.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. @@ -58,10 +57,9 @@ export async function createPotreeViewer( viewer.loadGUI(() => { viewer.setLanguage('en') - $('#menu_appearance').next().show() - $('#menu_tools').next().show() - $('#menu_scene').next().show() - $('#menu_filters').next().show() + //remove the header with language information + $('#sidebar_header').remove() + $('#menu_filters').remove() viewer.toggleSidebar() // Store the last used elevation gradient @@ -90,7 +88,7 @@ export async function createPotreeViewer( }) } - initThreePanels(viewer, { + initFilterPanels(viewer, { onActivateElevation: () => { const $ = window.jQuery || window.$ const slider = $ ? $('#sldHeightRange') : null @@ -125,15 +123,23 @@ export async function createPotreeViewer( makeGlobeBackgroundOption() + // Show compass + viewer.compass.setVisible(true) + + // Apply runtime overrides for the 2D Profile tool + init2DProfileOverride(viewer) + initMeasurementsPanel(viewer) initAnnotationsPanel(viewer) initMiniMap(viewer) + + makeMenuTabbable() }) // Initialize camera position and target point (manually chosen) viewer.scene.view.setView( [4094989.813, 59057.337, 8363694.681], // Initial camera position - [1500922.651, 510673.03, 5427934.722] // Initial target point + [1821061.22, 266704.21, 6084038.77] // Initial target point ) return viewer @@ -205,6 +211,9 @@ function overrideShaderForGradient(pc) { } } +// Prevent overlapping scroll freezing when activating filters quickly +let suppressionActive = false + /** * Freeze all scrollable ancestors of a given root during an action (e.g., jsTree select) * Need this so that when Elevation control or Accepted filter is activated the sidebar doesn't scroll down to the Scene panel @@ -212,6 +221,13 @@ function overrideShaderForGradient(pc) { * @param {*} action */ function suppressSidebarAutoScroll(action, holdMs = 350) { + // --- Re-entrancy guard --- + if (suppressionActive) { + action() + return + } + suppressionActive = true + // anchor on the tree root; fall back to the menu if not found const treeRoot = document.querySelector('#scene_objects') || @@ -228,8 +244,10 @@ function suppressSidebarAutoScroll(action, holdMs = 350) { if (canScroll) scrollers.push(el) el = el.parentElement } + if (!scrollers.length) { action() + suppressionActive = false return } @@ -261,20 +279,19 @@ function suppressSidebarAutoScroll(action, holdMs = 350) { const origFocus = HTMLElement.prototype.focus HTMLElement.prototype.focus = function (opts) { - // force preventScroll behavior even if caller didn't ask try { origFocus.call(this, { ...(opts || {}), preventScroll: true }) } catch { origFocus.call(this) } } + try { action() } finally { const until = performance.now() + holdMs const restoreLoop = () => { - // keep snapping until the selection animations/handlers settle states.forEach(({ el, top, left }) => { el.scrollTop = top el.scrollLeft = left @@ -297,8 +314,11 @@ function suppressSidebarAutoScroll(action, holdMs = 350) { if (h) el.removeEventListener('scroll', h) el.style.overflow = overflow }) + // --- Release the guard --- + suppressionActive = false } } + requestAnimationFrame(restoreLoop) } }