diff --git a/README.md b/README.md index d48ceab..518effe 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Molloy Explorer is a 3D seabed visualization tool built with **Potree**. It allo ### Add point cloud data -Place the point cloud data (in Potree format) in `public/pointclouds/data_converted`. +Place the point cloud data (in Potree format with EPSG:4978 coordinates) in `public/pointclouds/data_converted`. **Note:** Point cloud files should not be committed to Git. diff --git a/index.html b/index.html index d0563c8..432ff20 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,11 @@ type="text/css" href="/libs/jstree/themes/mixed/style.css" /> + @@ -40,60 +45,25 @@ +
-
+
+
+
- - diff --git a/src/ElevationControl/elevationControl.js b/src/ElevationControl/elevationControl.js index 276456b..c4f28ae 100644 --- a/src/ElevationControl/elevationControl.js +++ b/src/ElevationControl/elevationControl.js @@ -61,6 +61,13 @@ function rebindElevationLabel() { label.textContent = `${low.toFixed(2)} to ${high.toFixed(2)}` } + // Adjust slider limits + slider.slider({ + min: -10000, + max: 0, + values: [-10000, 0] + }) + //clear any old namespaced handlers and attach fresh ones slider.off('slide.custom slidestop.custom change.custom') slider.on('slide.custom', update) @@ -81,8 +88,7 @@ function moveElevationContainer() { } //initiate and orchestrate all funcitons to render the Evelation control section of the sidebar propperly -window.initElevationControls = function initElevationControls(viewer) { - +export function initElevationControls(viewer) { //Creates the section createElevationPanel(viewer) diff --git a/src/cameraSync.js b/src/cameraSync.js new file mode 100644 index 0000000..35fcd5e --- /dev/null +++ b/src/cameraSync.js @@ -0,0 +1,101 @@ +/** + * Syncs Potree's point cloud with Cesium's globe. + * + * @param potreeViewer - used for point cloud + * @param cesiumViewer - used for globe + */ +export function syncCameras(potreeViewer, cesiumViewer) { + const camera = potreeViewer.scene.getActiveCamera() + + // Compute camera position, up vector, and target (pivot) in world coordinates + const pPos = new THREE.Vector3(0, 0, 0).applyMatrix4(camera.matrixWorld) + const pUp = new THREE.Vector3(0, 600, 0).applyMatrix4(camera.matrixWorld) + const pTarget = potreeViewer.scene.view.getPivot() + + const toCes = (v) => new Cesium.Cartesian3(v.x, v.y, v.z) + + const cPos = toCes(pPos) + const cUpTarget = toCes(pUp) + const cTarget = toCes(pTarget) + + // Compute Cesium camera direction and up vectors + const cDir = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(cTarget, cPos, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ) + const cUp = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(cUpTarget, cPos, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ) + + // Hide globe when the camera is below the surface, blocked by the curvature of the Earth or directly above the pivot + const showGlobe = shouldShowGlobe(cPos, cTarget, cDir) + cesiumViewer.scene.globe.show = showGlobe + cesiumViewer.scene.skyAtmosphere.show = showGlobe + + // Sync Cesium camera position and orientation with Potree + cesiumViewer.camera.setView({ + destination: cPos, + orientation: { + direction: cDir, + up: cUp + } + }) + + // Match FOV + const aspect = camera.aspect + const fovy = Math.PI * (camera.fov / 180) + if (aspect < 1) { + cesiumViewer.camera.frustum.fov = fovy + } else { + const fovx = Math.atan(Math.tan(0.5 * fovy) * aspect) * 2 + cesiumViewer.camera.frustum.fov = fovx + } +} + +/** + * Determines whether the globe should be visible based on the camera position. + * + * Returns false if the camera is below the globe surface, if the pivot + * point would be blocked by the curvature of the Earth or if the camera + * is looking almost straight down at the pivot. + * + * @param cameraPos - The camera position in Cesium.Cartesian3 coordinates + * @param pivot - The pivot point in Cesium.Cartesian3 coordinates + * @param direction - The camera direction as a Cesium.Cartesian3 unit vector + * @returns true if the globe should be visible, false if it should be hidden + */ +function shouldShowGlobe(cameraPos, pivot, direction) { + const ellipsoid = Cesium.Ellipsoid.WGS84 + const earthCenter = Cesium.Cartesian3.ZERO + + // Get point on globe surface directly above the pivot + const carto = Cesium.Cartographic.fromCartesian(pivot) + const pivotSurface = Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + 0, + ellipsoid + ) + + // Axis vector from Earth center through pivot + const axis = Cesium.Cartesian3.subtract( + pivotSurface, + earthCenter, + new Cesium.Cartesian3() + ) + Cesium.Cartesian3.normalize(axis, axis) + + // Project camera and pivot onto this axis + const camProj = Cesium.Cartesian3.dot(cameraPos, axis) + const pivotProj = Cesium.Cartesian3.dot(pivotSurface, axis) + + // Compute the dot product between camera direction and local vertical + // Used to detect if the camera is looking almost straight down + const targetNormal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(pivot) + const dotProduct = Math.abs(Cesium.Cartesian3.dot(direction, targetNormal)) + + // If camera is "above" pivot on the axis, and not looking nearly straight down, the globe should be visible + // Otherwise, the globe should not be visible + return camProj >= pivotProj && dotProduct < 0.99 +} diff --git a/src/cesiumViewer.js b/src/cesiumViewer.js new file mode 100644 index 0000000..af8336e --- /dev/null +++ b/src/cesiumViewer.js @@ -0,0 +1,26 @@ +/** + * Initializes the Cesium viewer used to visualize the globe. + * + * @param containerId - id of the container + * @returns Cesium viewer + */ +export function createCesiumViewer(containerId) { + const viewer = new Cesium.Viewer(containerId, { + useDefaultRenderLoop: false, + animation: false, + baseLayerPicker: false, + fullscreenButton: false, + geocoder: false, + homeButton: false, + infoBox: false, + sceneModePicker: false, + selectionIndicator: false, + timeline: false, + navigationHelpButton: false, + imageryProvider: Cesium.createOpenStreetMapImageryProvider({ + url: 'https://a.tile.openstreetmap.org/' + }), + terrainShadows: Cesium.ShadowMode.DISABLED + }) + return viewer +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..fa55e71 --- /dev/null +++ b/src/config.js @@ -0,0 +1,7 @@ +export const POTREE_POINTCLOUD_URL = '/pointclouds/data_converted/metadata.json' + +export const POTREE_SETTINGS = { + edl: true, + fov: 60, + pointBudget: 1_000_000 +} diff --git a/src/main.js b/src/main.js index e69de29..82f7e1e 100644 --- a/src/main.js +++ b/src/main.js @@ -0,0 +1,26 @@ +import { POTREE_POINTCLOUD_URL, POTREE_SETTINGS } from './config.js' +import { createCesiumViewer } from './cesiumViewer.js' +import { createPotreeViewer } from './potreeViewer.js' +import { syncCameras } from './cameraSync.js' + +async function init() { + window.cesiumViewer = createCesiumViewer('cesiumContainer') + + window.potreeViewer = await createPotreeViewer( + 'potree_render_area', + POTREE_POINTCLOUD_URL, + POTREE_SETTINGS + ) + + function loop(timestamp) { + requestAnimationFrame(loop) + potreeViewer.update(potreeViewer.clock.getDelta(), timestamp) + potreeViewer.render() + syncCameras(potreeViewer, cesiumViewer) + cesiumViewer.render() + } + + requestAnimationFrame(loop) +} + +init() diff --git a/src/potreeViewer.js b/src/potreeViewer.js new file mode 100644 index 0000000..3f04686 --- /dev/null +++ b/src/potreeViewer.js @@ -0,0 +1,133 @@ +import { initElevationControls } from './ElevationControl/elevationControl.js' + +/** + * Initializes the Potree viewer used to visualize the point cloud. + * + * @param containerId - id of the container + * @param pointcloudUrl - url path to the point cloud + * @param settings - other settings + * @returns Potree viewer + */ +export async function createPotreeViewer(containerId, pointcloudUrl, settings) { + const viewer = new Potree.Viewer(document.getElementById(containerId), { + useDefaultRenderLoop: false + }) + + // Remove original scroll listener and add new one + const oc = viewer.orbitControls + oc.removeEventListener('mousewheel', oc._listeners?.mousewheel?.[0]) + oc.addEventListener('mousewheel', clampScrollRadius) + + if (settings.edl) viewer.setEDLEnabled(true) + if (settings.fov) viewer.setFOV(settings.fov) + if (settings.pointBudget) viewer.setPointBudget(settings.pointBudget) + + viewer.loadSettingsFromURL() + viewer.setDescription('Molloy Explorer') + + viewer.loadGUI(() => { + viewer.setLanguage('en') + $('#menu_appearance').next().show() + $('#menu_tools').next().show() + $('#menu_scene').next().show() + $('#menu_filters').next().show() + viewer.toggleSidebar() + + initElevationControls(viewer) + }) + + const e = await Potree.loadPointCloud(pointcloudUrl) + const pc = e.pointcloud + viewer.scene.addPointCloud(pc) + + // Change name of default background from 'None' to 'Globe"' + $('#background_options_none') + .text('Globe') + .attr('id', 'background_options_globe') + .val('globe') + + viewer.setBackground('globe') + + pc.material.pointSizeType = Potree.PointSizeType.ADAPTIVE + pc.material.shape = Potree.PointShape.CIRCLE + overrideShaderForGradient(pc) + pc.material.elevationRange = [-10000, 0] + pc.material.activeAttributeName = 'elevation' + pc.material.gradient = Potree.Gradients['VIRIDIS'] + + e.pointcloud.projection = '+proj=geocent +datum=WGS84 +units=m +no_defs' + + // Initialize camera position and target point (manually chosen) + viewer.scene.view.setView( + [1993552.9, 87954.487, 7134018.721], // Initial camera position + [1184471.63, 63828.49, 6243615.52] // Initial target point + ) + + return viewer +} + +/** + * Replacement for original scroll function which limits how far you can zoom out. + * + * @param e - the given event + */ +function clampScrollRadius(e) { + let resolvedRadius = this.scene.view.radius + this.radiusDelta + let newRadius = resolvedRadius - e.delta * resolvedRadius * 0.1 + + const maxRadius = 10000000 + if (newRadius > maxRadius) newRadius = maxRadius + + this.radiusDelta = newRadius - this.scene.view.radius + this.stopTweens() +} + +/** + * Adjust shader to use elevation relative to sealevel for EPSG:4978 coordinates. + * + * @param pc - the point cloud + */ +function overrideShaderForGradient(pc) { + const originalUpdateShaderSource = pc.material.updateShaderSource + pc.material.updateShaderSource = function () { + // Call the original updateShaderSource first + originalUpdateShaderSource.call(this) + + // Override the shader's getElevation function to use elevation relative to sealevel + this.vertexShader = this.vertexShader.replace( + /vec3 getElevation\(\)[\s\S]*?\}/, + ` + vec3 getElevation(){ + // Transform the vertex position into world coordinates + vec4 world = modelMatrix * vec4(position, 1.0); + + // Compute distance from Earth's center and latitude + float radius = length(world.xyz); + float latitude = asin(world.z / radius); + + const float a = 6378137.0; // Equatorial radius + const float b = 6356752.3; // Polar radius + + // Compute distance from Earth's center to the surface at the given latitude + float cosLat = cos(latitude); + float sinLat = sin(latitude); + float numerator = (a*a * cosLat) * (a*a * cosLat) + (b*b * sinLat) * (b*b * sinLat); + float denominator = (a * cosLat) * (a * cosLat) + (b * sinLat) * (b * sinLat); + float radiusAtLatitude = sqrt(numerator / denominator); + + // Compute depth below the ellipsoid (sea level) + float depth = radius - radiusAtLatitude; + + // Normalize depth to a [0, 1] range for coloring + float w = (depth - elevationRange.x) / (elevationRange.y - elevationRange.x); + + // Sample color from gradient texture based on normalized depth + return texture2D(gradient, vec2(w, 1.0-w)).rgb; + } + ` + ) + + // Mark the material as needing recompilation + this.needsUpdate = true + } +}