factor: Switch to Web Mercator coordinates
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
Convert LAS lidar files to .mound binary format for Three.js rendering.
|
Convert LAS lidar files to .mound binary format for Three.js rendering.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python las_to_mound.py input.las output.mound
|
python las2mound.py input.las output.mound
|
||||||
|
|
||||||
.mound Binary Format Specification
|
.mound Binary Format Specification
|
||||||
==================================
|
==================================
|
||||||
@@ -15,16 +15,18 @@ Header (64 bytes):
|
|||||||
4 4 uint32 Version number (currently 1)
|
4 4 uint32 Version number (currently 1)
|
||||||
8 4 uint32 Point count (number of vertices)
|
8 4 uint32 Point count (number of vertices)
|
||||||
12 4 uint32 Triangle count (number of triangles)
|
12 4 uint32 Triangle count (number of triangles)
|
||||||
16 4 float32 Min X coordinate
|
16 4 float32 Min X coordinate (Web Mercator meters)
|
||||||
20 4 float32 Min Y coordinate
|
20 4 float32 Min Y coordinate (Web Mercator meters)
|
||||||
24 4 float32 Min Z coordinate
|
24 4 float32 Min Z coordinate (elevation in meters)
|
||||||
28 4 float32 Max X coordinate
|
28 4 float32 Max X coordinate (Web Mercator meters)
|
||||||
32 4 float32 Max Y coordinate
|
32 4 float32 Max Y coordinate (Web Mercator meters)
|
||||||
36 4 float32 Max Z coordinate
|
36 4 float32 Max Z coordinate (elevation in meters)
|
||||||
40 24 bytes Reserved (padding to 64 bytes)
|
40 24 bytes Reserved (padding to 64 bytes)
|
||||||
|
|
||||||
Vertex Data (point_count * 12 bytes):
|
Vertex Data (point_count * 12 bytes):
|
||||||
Series of vertices in XYZ float32 triplets.
|
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
|
Total size: point_count * 3 * 4 bytes
|
||||||
|
|
||||||
Index Data (triangle_count * 12 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)
|
Bytes 1264-1863: Index data (50 * 12)
|
||||||
Total file size: 1864 bytes
|
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
|
import sys
|
||||||
@@ -64,24 +70,26 @@ except ImportError:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def transform_to_latlon(x, y, z):
|
def transform_to_webmercator(x, y, z):
|
||||||
"""Transform Ohio State Plane coordinates to lat/lon."""
|
"""Transform Ohio State Plane coordinates to Web Mercator (EPSG:3857)."""
|
||||||
print("Transforming coordinates to lat/lon...")
|
print("Transforming coordinates to Web Mercator...")
|
||||||
|
|
||||||
# Ohio State Plane South (EPSG:3735) in US Survey Feet to WGS84 (EPSG:4326)
|
# Ohio State Plane North (EPSG:3734) in US Survey Feet to Web Mercator (EPSG:3857) in meters
|
||||||
# Note: Ohio has two zones - North (3734) and South (3735)
|
|
||||||
# Newark is in the North zone
|
# 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
|
# Transform x,y (easting, northing) to Web Mercator meters
|
||||||
lon, lat = transformer.transform(x, y)
|
merc_x, merc_y = transformer.transform(x, y)
|
||||||
|
|
||||||
# Convert elevation from US Survey Feet to meters
|
# Convert elevation from US Survey Feet to meters
|
||||||
z_meters = z * 0.3048006096012192
|
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):
|
def read_las(filepath):
|
||||||
@@ -164,7 +172,7 @@ def write_mound(filepath, x, y, z, indices):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
if len(sys.argv) != 3:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
input_file = sys.argv[1]
|
input_file = sys.argv[1]
|
||||||
@@ -177,14 +185,14 @@ def main():
|
|||||||
# Read LAS
|
# Read LAS
|
||||||
x, y, z = read_las(input_file)
|
x, y, z = read_las(input_file)
|
||||||
|
|
||||||
# Transform to lat/lon
|
# Transform to Web Mercator
|
||||||
lon, lat, z_meters = transform_to_latlon(x, y, z)
|
merc_x, merc_y, z_meters = transform_to_webmercator(x, y, z)
|
||||||
|
|
||||||
# Triangulate (using lon/lat as x/y)
|
# Triangulate (using Web Mercator coordinates)
|
||||||
indices = triangulate_points(lon, lat, z_meters)
|
indices = triangulate_points(merc_x, merc_y, z_meters)
|
||||||
|
|
||||||
# Write output (lon as x, lat as y, elevation as z)
|
# Write output (Web Mercator X/Y in meters, elevation in meters)
|
||||||
write_mound(output_file, lon, lat, z_meters, indices)
|
write_mound(output_file, merc_x, merc_y, z_meters, indices)
|
||||||
|
|
||||||
print("Done!")
|
print("Done!")
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,14 @@ export default {
|
|||||||
if (renderer) renderer.render(scene, camera);
|
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 = () => {
|
const initThreeJS = () => {
|
||||||
scene = new THREE.Scene();
|
scene = new THREE.Scene();
|
||||||
|
|
||||||
@@ -230,10 +238,14 @@ export default {
|
|||||||
const ne = bounds.getNorthEast();
|
const ne = bounds.getNorthEast();
|
||||||
const sw = bounds.getSouthWest();
|
const sw = bounds.getSouthWest();
|
||||||
|
|
||||||
camera.left = sw.lng;
|
// Convert lat/lon bounds to Web Mercator meters
|
||||||
camera.right = ne.lng;
|
const neMerc = lonLatToWebMercator(ne.lng, ne.lat);
|
||||||
camera.top = ne.lat;
|
const swMerc = lonLatToWebMercator(sw.lng, sw.lat);
|
||||||
camera.bottom = sw.lat;
|
|
||||||
|
camera.left = swMerc.x;
|
||||||
|
camera.right = neMerc.x;
|
||||||
|
camera.top = neMerc.y;
|
||||||
|
camera.bottom = swMerc.y;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ const settings = reactive({
|
|||||||
altitude: 60,
|
altitude: 60,
|
||||||
intensity: 1.2,
|
intensity: 1.2,
|
||||||
heightScale: 3,
|
heightScale: 3,
|
||||||
terrainColor: "#9a9996",
|
terrainColor: 0x9A9996,
|
||||||
...props.initialSettings
|
...props.initialSettings
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,12 +249,31 @@ const handleResize = () => {
|
|||||||
renderer.setSize(width, height);
|
renderer.setSize(width, height);
|
||||||
renderer.setPixelRatio(window.devicePixelRatio);
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
|
||||||
// Always maintain square frustum regardless of canvas aspect ratio
|
// 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;
|
const viewSize = 6;
|
||||||
camera.left = -viewSize;
|
camera.left = -viewSize;
|
||||||
camera.right = viewSize;
|
camera.right = viewSize;
|
||||||
camera.top = viewSize;
|
camera.top = viewSize;
|
||||||
camera.bottom = -viewSize;
|
camera.bottom = -viewSize;
|
||||||
|
}
|
||||||
|
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -285,17 +304,20 @@ const loadTileData = (tileData) => {
|
|||||||
const spanZ = tileData.bounds.maxZ - tileData.bounds.minZ;
|
const spanZ = tileData.bounds.maxZ - tileData.bounds.minZ;
|
||||||
const maxSpan = Math.max(spanX, spanY);
|
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;
|
const normalizeScale = 10 / maxSpan;
|
||||||
|
|
||||||
// CRITICAL: Use App2's adaptive Z scaling
|
// Z scaling: make Z variation visible but proportional
|
||||||
// This makes Z proportional to actual elevation variation
|
|
||||||
const zScale = normalizeScale * (maxSpan * 0.1) / spanZ;
|
const zScale = normalizeScale * (maxSpan * 0.1) / spanZ;
|
||||||
|
|
||||||
console.log('Tile scaling:', {
|
console.log('Tile scaling:', {
|
||||||
spanX: spanX.toFixed(2),
|
spanX: spanX.toFixed(2),
|
||||||
spanY: spanY.toFixed(2),
|
spanY: spanY.toFixed(2),
|
||||||
spanZ: spanZ.toFixed(2),
|
spanZ: spanZ.toFixed(2),
|
||||||
|
tileAspect: tileAspect.toFixed(3),
|
||||||
normalizeScale: normalizeScale.toFixed(4),
|
normalizeScale: normalizeScale.toFixed(4),
|
||||||
zScale: zScale.toFixed(4)
|
zScale: zScale.toFixed(4)
|
||||||
});
|
});
|
||||||
@@ -314,7 +336,7 @@ const loadTileData = (tileData) => {
|
|||||||
geometry.setIndex(new THREE.BufferAttribute(tileData.indices, 1));
|
geometry.setIndex(new THREE.BufferAttribute(tileData.indices, 1));
|
||||||
geometry.computeVertexNormals();
|
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);
|
const baseZ = new Float32Array(tileData.positions.length);
|
||||||
for (let i = 0; i < tileData.positions.length; i += 3) {
|
for (let i = 0; i < tileData.positions.length; i += 3) {
|
||||||
baseZ[i] = 0;
|
baseZ[i] = 0;
|
||||||
@@ -326,7 +348,8 @@ const loadTileData = (tileData) => {
|
|||||||
geometry,
|
geometry,
|
||||||
baseZ,
|
baseZ,
|
||||||
spanZ,
|
spanZ,
|
||||||
zScale
|
zScale,
|
||||||
|
tileAspect
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create material and mesh
|
// Create material and mesh
|
||||||
@@ -338,15 +361,25 @@ const loadTileData = (tileData) => {
|
|||||||
mesh = new THREE.Mesh(geometry, material);
|
mesh = new THREE.Mesh(geometry, material);
|
||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
|
|
||||||
// Configure camera frustum for orthographic view - ALWAYS SQUARE
|
// Configure camera frustum to match tile aspect ratio
|
||||||
const viewSize = 6; // 10-unit terrain + padding
|
const viewSize = 6; // Base size for the view
|
||||||
camera.left = -viewSize;
|
|
||||||
camera.right = viewSize;
|
// 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.top = viewSize;
|
||||||
camera.bottom = -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
|
// 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
|
const maxZExtent = (spanZ * zScale / 2) * 20; // Max exaggeration
|
||||||
camera.near = 0.1;
|
camera.near = 0.1;
|
||||||
camera.far = 100 + maxZExtent * 2;
|
camera.far = 100 + maxZExtent * 2;
|
||||||
@@ -523,11 +556,11 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
|
|||||||
const originalHeight = renderer.domElement.height;
|
const originalHeight = renderer.domElement.height;
|
||||||
const originalPixelRatio = renderer.getPixelRatio();
|
const originalPixelRatio = renderer.getPixelRatio();
|
||||||
|
|
||||||
// Set render size (square)
|
// Set render size (square for export)
|
||||||
renderer.setSize(resolution, resolution);
|
renderer.setSize(resolution, resolution);
|
||||||
renderer.setPixelRatio(1);
|
renderer.setPixelRatio(1);
|
||||||
|
|
||||||
// Update camera for square aspect (always square)
|
// Update camera for square render output
|
||||||
const viewSize = 6;
|
const viewSize = 6;
|
||||||
camera.left = -viewSize;
|
camera.left = -viewSize;
|
||||||
camera.right = viewSize;
|
camera.right = viewSize;
|
||||||
@@ -545,11 +578,28 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
|
|||||||
renderer.setSize(originalWidth / originalPixelRatio, originalHeight / originalPixelRatio);
|
renderer.setSize(originalWidth / originalPixelRatio, originalHeight / originalPixelRatio);
|
||||||
renderer.setPixelRatio(originalPixelRatio);
|
renderer.setPixelRatio(originalPixelRatio);
|
||||||
|
|
||||||
// Restore camera (always square frustum)
|
// 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.left = -viewSize;
|
||||||
camera.right = viewSize;
|
camera.right = viewSize;
|
||||||
camera.top = viewSize;
|
camera.top = viewSize;
|
||||||
camera.bottom = -viewSize;
|
camera.bottom = -viewSize;
|
||||||
|
}
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
|
|||||||
Reference in New Issue
Block a user