From 08cfcdee190f56fe2934edaaf32daa990cc98bbc Mon Sep 17 00:00:00 2001 From: Mark Kalsbeek Date: Wed, 21 Jan 2026 00:25:46 +0100 Subject: [PATCH] factor: Switch to Web Mercator coordinates --- tooling/las2mound.py | 56 ++++++++++++--------- ui/src/App.vue | 20 ++++++-- ui/src/ShadingSandbox.vue | 102 ++++++++++++++++++++++++++++---------- 3 files changed, 124 insertions(+), 54 deletions(-) diff --git a/tooling/las2mound.py b/tooling/las2mound.py index 32e28dd..9e105b2 100644 --- a/tooling/las2mound.py +++ b/tooling/las2mound.py @@ -3,7 +3,7 @@ Convert LAS lidar files to .mound binary format for Three.js rendering. Usage: - python las_to_mound.py input.las output.mound + python las2mound.py input.las output.mound .mound Binary Format Specification ================================== @@ -15,16 +15,18 @@ Header (64 bytes): 4 4 uint32 Version number (currently 1) 8 4 uint32 Point count (number of vertices) 12 4 uint32 Triangle count (number of triangles) - 16 4 float32 Min X coordinate - 20 4 float32 Min Y coordinate - 24 4 float32 Min Z coordinate - 28 4 float32 Max X coordinate - 32 4 float32 Max Y coordinate - 36 4 float32 Max Z coordinate + 16 4 float32 Min X coordinate (Web Mercator meters) + 20 4 float32 Min Y coordinate (Web Mercator meters) + 24 4 float32 Min Z coordinate (elevation in meters) + 28 4 float32 Max X coordinate (Web Mercator meters) + 32 4 float32 Max Y coordinate (Web Mercator meters) + 36 4 float32 Max Z coordinate (elevation in meters) 40 24 bytes Reserved (padding to 64 bytes) Vertex Data (point_count * 12 bytes): Series of vertices in XYZ float32 triplets. + X, Y are in Web Mercator meters (EPSG:3857) + Z is elevation in meters Total size: point_count * 3 * 4 bytes Index Data (triangle_count * 12 bytes): @@ -38,6 +40,10 @@ Example layout for 100 points and 50 triangles: Bytes 1264-1863: Index data (50 * 12) Total file size: 1864 bytes +Coordinate System: + Input: Ohio State Plane North (EPSG:3734) in US Survey Feet + Output: Web Mercator (EPSG:3857) in meters + This ensures compatibility with MapLibre GL JS and web mapping standards. """ import sys @@ -64,24 +70,26 @@ except ImportError: sys.exit(1) -def transform_to_latlon(x, y, z): - """Transform Ohio State Plane coordinates to lat/lon.""" - print("Transforming coordinates to lat/lon...") +def transform_to_webmercator(x, y, z): + """Transform Ohio State Plane coordinates to Web Mercator (EPSG:3857).""" + print("Transforming coordinates to Web Mercator...") - # Ohio State Plane South (EPSG:3735) in US Survey Feet to WGS84 (EPSG:4326) - # Note: Ohio has two zones - North (3734) and South (3735) + # Ohio State Plane North (EPSG:3734) in US Survey Feet to Web Mercator (EPSG:3857) in meters # Newark is in the North zone - transformer = Transformer.from_crs("EPSG:3734", "EPSG:4326", always_xy=True) + transformer = Transformer.from_crs("EPSG:3734", "EPSG:3857", always_xy=True) - # Transform x,y (easting, northing) to lon, lat - lon, lat = transformer.transform(x, y) + # Transform x,y (easting, northing) to Web Mercator meters + merc_x, merc_y = transformer.transform(x, y) # Convert elevation from US Survey Feet to meters z_meters = z * 0.3048006096012192 - print(f"Transformed to lat/lon bounds: lon[{lon.min():.6f}, {lon.max():.6f}] lat[{lat.min():.6f}, {lat.max():.6f}]") + print(f"Transformed to Web Mercator bounds:") + print(f" X (meters): [{merc_x.min():.2f}, {merc_x.max():.2f}] (span: {merc_x.max() - merc_x.min():.2f}m)") + print(f" Y (meters): [{merc_y.min():.2f}, {merc_y.max():.2f}] (span: {merc_y.max() - merc_y.min():.2f}m)") + print(f" Z (meters): [{z_meters.min():.2f}, {z_meters.max():.2f}] (span: {z_meters.max() - z_meters.min():.2f}m)") - return lon, lat, z_meters + return merc_x, merc_y, z_meters def read_las(filepath): @@ -164,7 +172,7 @@ def write_mound(filepath, x, y, z, indices): def main(): if len(sys.argv) != 3: - print("Usage: python las_to_mound.py input.las output.mound") + print("Usage: python las2mound.py input.las output.mound") sys.exit(1) input_file = sys.argv[1] @@ -177,14 +185,14 @@ def main(): # Read LAS x, y, z = read_las(input_file) - # Transform to lat/lon - lon, lat, z_meters = transform_to_latlon(x, y, z) + # Transform to Web Mercator + merc_x, merc_y, z_meters = transform_to_webmercator(x, y, z) - # Triangulate (using lon/lat as x/y) - indices = triangulate_points(lon, lat, z_meters) + # Triangulate (using Web Mercator coordinates) + indices = triangulate_points(merc_x, merc_y, z_meters) - # Write output (lon as x, lat as y, elevation as z) - write_mound(output_file, lon, lat, z_meters, indices) + # Write output (Web Mercator X/Y in meters, elevation in meters) + write_mound(output_file, merc_x, merc_y, z_meters, indices) print("Done!") diff --git a/ui/src/App.vue b/ui/src/App.vue index fe5b633..4140afa 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -188,6 +188,14 @@ export default { }); if (renderer) renderer.render(scene, camera); }; + + // Convert lat/lon to Web Mercator meters (EPSG:3857) + const lonLatToWebMercator = (lon, lat) => { + const x = lon * 20037508.34 / 180; + let y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180); + y = y * 20037508.34 / 180; + return { x, y }; + }; const initThreeJS = () => { scene = new THREE.Scene(); @@ -230,10 +238,14 @@ export default { const ne = bounds.getNorthEast(); const sw = bounds.getSouthWest(); - camera.left = sw.lng; - camera.right = ne.lng; - camera.top = ne.lat; - camera.bottom = sw.lat; + // Convert lat/lon bounds to Web Mercator meters + const neMerc = lonLatToWebMercator(ne.lng, ne.lat); + const swMerc = lonLatToWebMercator(sw.lng, sw.lat); + + camera.left = swMerc.x; + camera.right = neMerc.x; + camera.top = neMerc.y; + camera.bottom = swMerc.y; camera.updateProjectionMatrix(); renderer.render(scene, camera); diff --git a/ui/src/ShadingSandbox.vue b/ui/src/ShadingSandbox.vue index f591af1..1c34a42 100644 --- a/ui/src/ShadingSandbox.vue +++ b/ui/src/ShadingSandbox.vue @@ -165,7 +165,7 @@ const settings = reactive({ altitude: 60, intensity: 1.2, heightScale: 3, - terrainColor: "#9a9996", + terrainColor: 0x9A9996, ...props.initialSettings }); @@ -249,12 +249,31 @@ const handleResize = () => { renderer.setSize(width, height); renderer.setPixelRatio(window.devicePixelRatio); - // Always maintain square frustum regardless of canvas aspect ratio - const viewSize = 6; - camera.left = -viewSize; - camera.right = viewSize; - camera.top = viewSize; - camera.bottom = -viewSize; + // Maintain tile aspect ratio in frustum + if (geometryCache && geometryCache.tileAspect) { + const viewSize = 6; + const tileAspect = geometryCache.tileAspect; + + if (tileAspect > 1) { + camera.left = -viewSize * tileAspect; + camera.right = viewSize * tileAspect; + camera.top = viewSize; + camera.bottom = -viewSize; + } else { + camera.left = -viewSize; + camera.right = viewSize; + camera.top = viewSize / tileAspect; + camera.bottom = -viewSize / tileAspect; + } + } else { + // Fallback: square frustum if no tile loaded yet + const viewSize = 6; + camera.left = -viewSize; + camera.right = viewSize; + camera.top = viewSize; + camera.bottom = -viewSize; + } + camera.updateProjectionMatrix(); }; @@ -285,17 +304,20 @@ const loadTileData = (tileData) => { const spanZ = tileData.bounds.maxZ - tileData.bounds.minZ; const maxSpan = Math.max(spanX, spanY); - // Normalize XY to fit in a 10-unit box + // Calculate actual aspect ratio of the tile + const tileAspect = spanX / spanY; + + // Normalize XY to fit in view, maintaining actual aspect ratio const normalizeScale = 10 / maxSpan; - // CRITICAL: Use App2's adaptive Z scaling - // This makes Z proportional to actual elevation variation + // Z scaling: make Z variation visible but proportional const zScale = normalizeScale * (maxSpan * 0.1) / spanZ; console.log('Tile scaling:', { spanX: spanX.toFixed(2), spanY: spanY.toFixed(2), spanZ: spanZ.toFixed(2), + tileAspect: tileAspect.toFixed(3), normalizeScale: normalizeScale.toFixed(4), zScale: zScale.toFixed(4) }); @@ -314,7 +336,7 @@ const loadTileData = (tileData) => { geometry.setIndex(new THREE.BufferAttribute(tileData.indices, 1)); geometry.computeVertexNormals(); - // Cache base Z values for height exaggeration (matching App2's approach) + // Cache base Z values for height exaggeration const baseZ = new Float32Array(tileData.positions.length); for (let i = 0; i < tileData.positions.length; i += 3) { baseZ[i] = 0; @@ -326,7 +348,8 @@ const loadTileData = (tileData) => { geometry, baseZ, spanZ, - zScale + zScale, + tileAspect }; // Create material and mesh @@ -338,15 +361,25 @@ const loadTileData = (tileData) => { mesh = new THREE.Mesh(geometry, material); scene.add(mesh); - // Configure camera frustum for orthographic view - ALWAYS SQUARE - const viewSize = 6; // 10-unit terrain + padding - camera.left = -viewSize; - camera.right = viewSize; - camera.top = viewSize; - camera.bottom = -viewSize; + // Configure camera frustum to match tile aspect ratio + const viewSize = 6; // Base size for the view + + // Adjust frustum based on tile aspect ratio + if (tileAspect > 1) { + // Wider than tall + camera.left = -viewSize * tileAspect; + camera.right = viewSize * tileAspect; + camera.top = viewSize; + camera.bottom = -viewSize; + } else { + // Taller than wide + camera.left = -viewSize; + camera.right = viewSize; + camera.top = viewSize / tileAspect; + camera.bottom = -viewSize / tileAspect; + } // Adjust near/far to accommodate height exaggeration - // Terrain is centered at Z=0, extends ±(spanZ * zScale / 2) in base form const maxZExtent = (spanZ * zScale / 2) * 20; // Max exaggeration camera.near = 0.1; camera.far = 100 + maxZExtent * 2; @@ -523,11 +556,11 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102 const originalHeight = renderer.domElement.height; const originalPixelRatio = renderer.getPixelRatio(); - // Set render size (square) + // Set render size (square for export) renderer.setSize(resolution, resolution); renderer.setPixelRatio(1); - // Update camera for square aspect (always square) + // Update camera for square render output const viewSize = 6; camera.left = -viewSize; camera.right = viewSize; @@ -545,11 +578,28 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102 renderer.setSize(originalWidth / originalPixelRatio, originalHeight / originalPixelRatio); renderer.setPixelRatio(originalPixelRatio); - // Restore camera (always square frustum) - camera.left = -viewSize; - camera.right = viewSize; - camera.top = viewSize; - camera.bottom = -viewSize; + // Restore camera with tile aspect ratio + if (geometryCache && geometryCache.tileAspect) { + const tileAspect = geometryCache.tileAspect; + + if (tileAspect > 1) { + camera.left = -viewSize * tileAspect; + camera.right = viewSize * tileAspect; + camera.top = viewSize; + camera.bottom = -viewSize; + } else { + camera.left = -viewSize; + camera.right = viewSize; + camera.top = viewSize / tileAspect; + camera.bottom = -viewSize / tileAspect; + } + } else { + // Fallback: square frustum + camera.left = -viewSize; + camera.right = viewSize; + camera.top = viewSize; + camera.bottom = -viewSize; + } camera.updateProjectionMatrix(); const endTime = performance.now();