improved osm renderning: full building outlines, roads, greenspace, water

This commit is contained in:
Evan Scamehorn
2025-11-26 16:09:03 -06:00
parent c4b29f5a20
commit 059a58fbf7
3 changed files with 288 additions and 162 deletions

View File

@@ -2,89 +2,146 @@ import osmnx as ox
import json import json
import os import os
# ==========================================
# 1. Configuration # 1. Configuration
# ==========================================
PLACE_NAME = "Wisconsin State Capitol, Madison, USA" PLACE_NAME = "Wisconsin State Capitol, Madison, USA"
DIST = 2400 # Meters radius around center DIST = 3000
# 2. Download Data # ==========================================
# 2. Data Fetching
# ==========================================
print(f"Downloading data for {PLACE_NAME}...") print(f"Downloading data for {PLACE_NAME}...")
tags = {"building": True}
# UPDATED FOR V2.0: Access features module explicitly # Define tags for different layers
tags = {
"building": True,
"natural": ["water", "bay", "coastline"],
"landuse": ["grass", "forest", "park", "recreation_ground"],
"leisure": ["park", "garden"],
"highway": True,
}
try: try:
# Try new v2.0 syntax # OSMNX v2.0+
gdf = ox.features.features_from_address(PLACE_NAME, tags=tags, dist=DIST) gdf = ox.features.features_from_address(PLACE_NAME, tags=tags, dist=DIST)
except AttributeError: except AttributeError:
# Fallback for older versions # OSMNX < v2.0
gdf = ox.features_from_address(PLACE_NAME, tags=tags, dist=DIST) gdf = ox.features_from_address(PLACE_NAME, tags=tags, dist=DIST)
# 3. Project to meters (Local Grid) # ==========================================
# UPDATED FOR V2.0: Use GeoPandas native estimation # 3. Projection & Normalization
# ==========================================
print("Projecting to local grid...") print("Projecting to local grid...")
gdf_proj = gdf.to_crs(gdf.estimate_utm_crs()) gdf_proj = gdf.to_crs(gdf.estimate_utm_crs())
# 4. Prepare Data for THREE.js # Calculate center for (0,0,0) normalization
buildings = []
# Calculate center to normalize coordinates to (0,0)
center_x = gdf_proj.geometry.centroid.x.mean() center_x = gdf_proj.geometry.centroid.x.mean()
center_y = gdf_proj.geometry.centroid.y.mean() center_y = gdf_proj.geometry.centroid.y.mean()
print("Processing geometry...") # ==========================================
for _, row in gdf_proj.iterrows(): # 4. Processing Functions
if row.geometry.geom_type == "Polygon": # ==========================================
# Get dimensions
minx, miny, maxx, maxy = row.geometry.bounds
width = maxx - minx
depth = maxy - miny
# Get Height (Clean dirty data)
height = 10 # Default fallback
# Check for 'height' tag def get_height(row):
if "height" in row and str(row["height"]) != "nan": """Estimates building height from tags."""
try: h = 10.0 # Default
# Clean strings like "10 m" or "approx 10" if "height" in row and str(row["height"]).lower() != "nan":
clean_h = "".join( try:
filter(lambda x: x.isdigit() or x == ".", str(row["height"])) # Extract numeric part
clean = "".join(
filter(lambda x: x.isdigit() or x == ".", str(row["height"]))
)
h = float(clean)
except:
pass
elif "building:levels" in row and str(row["building:levels"]).lower() != "nan":
try:
clean = "".join(
filter(lambda x: x.isdigit() or x == ".", str(row["building:levels"]))
)
h = float(clean) * 3.5
except:
pass
return round(h, 1)
def parse_polygon(geom):
"""Extracts exterior coordinates from a Polygon."""
if geom.is_empty:
return []
coords = list(geom.exterior.coords)
# Simplify slightly to reduce vertex count if needed, or keep raw
return [[round(x - center_x, 1), round(y - center_y, 1)] for x, y in coords]
def parse_linestring(geom):
"""Extracts coordinates from a LineString."""
if geom.is_empty:
return []
coords = list(geom.coords)
return [[round(x - center_x, 1), round(y - center_y, 1)] for x, y in coords]
# ==========================================
# 5. Categorization Loop
# ==========================================
output_data = {"buildings": [], "water": [], "parks": [], "roads": []}
print("Processing geometries...")
for idx, row in gdf_proj.iterrows():
geom = row.geometry
# Handle MultiPolygons by iterating over them
geoms = [geom] if geom.geom_type in ["Polygon", "LineString"] else []
if geom.geom_type == "MultiPolygon":
geoms = list(geom.geoms)
elif geom.geom_type == "MultiLineString":
geoms = list(geom.geoms)
for sub_geom in geoms:
# 1. BUILDINGS
if "building" in row and str(row["building"]) != "nan":
if sub_geom.geom_type == "Polygon":
output_data["buildings"].append(
{"shape": parse_polygon(sub_geom), "height": get_height(row)}
) )
height = float(clean_h)
except:
pass
# Check for 'building:levels' tag
elif "building:levels" in row and str(row["building:levels"]) != "nan":
try:
clean_l = "".join(
filter(
lambda x: x.isdigit() or x == ".", str(row["building:levels"])
)
)
height = float(clean_l) * 3.5 # Approx 3.5m per floor
except:
pass
# Normalize position relative to center # 2. WATER
x = (minx + maxx) / 2 - center_x elif ("natural" in row and row["natural"] in tags["natural"]) or (
z = center_y - (miny + maxy) / 2 # Invert Y for 3D Z-axis "water" in row and str(row["water"]) != "nan"
):
if sub_geom.geom_type == "Polygon":
output_data["water"].append({"shape": parse_polygon(sub_geom)})
# Add to array: [x, z, width, depth, height] # 3. PARKS / GREENSPACE
buildings.append( elif ("leisure" in row and row["leisure"] in tags["leisure"]) or (
[ "landuse" in row and row["landuse"] in tags["landuse"]
round(x, 1), ):
round(z, 1), if sub_geom.geom_type == "Polygon":
round(width, 1), output_data["parks"].append({"shape": parse_polygon(sub_geom)})
round(depth, 1),
round(height, 1),
]
)
# 5. Save to Public folder # 4. ROADS
elif "highway" in row and str(row["highway"]) != "nan":
if sub_geom.geom_type == "LineString":
output_data["roads"].append({"path": parse_linestring(sub_geom)})
# ==========================================
# 6. Save File
# ==========================================
output_path = os.path.join(os.path.dirname(__file__), "../public/city_data.json") output_path = os.path.join(os.path.dirname(__file__), "../public/city_data.json")
# Ensure directory exists just in case
os.makedirs(os.path.dirname(output_path), exist_ok=True) os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w") as f: with open(output_path, "w") as f:
json.dump(buildings, f) json.dump(output_data, f)
print(f"Done! Saved {len(buildings)} buildings to {output_path}") print(
f"Exported:"
f"\n Buildings: {len(output_data['buildings'])}"
f"\n Roads: {len(output_data['roads'])}"
f"\n Water: {len(output_data['water'])}"
f"\n Parks: {len(output_data['parks'])}"
)
print(f"Saved to {output_path}")

File diff suppressed because one or more lines are too long

View File

@@ -1,64 +1,56 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { MapControls } from 'three/addons/controls/MapControls.js'; import { MapControls } from 'three/addons/controls/MapControls.js';
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
// ========================================== // ==========================================
// 1. Configuration & Constants // 1. Configuration & Constants
// ========================================== // ==========================================
const SETTINGS = { const SETTINGS = {
colors: { colors: {
background: 0xcccccc, background: 0xe0e0e0, // Lighter fog
ground: 0x999999, ground: 0xdddddd,
building: 0xccffff, building: 0xffffff,
water: 0x4fa4e4,
park: 0x98d98e,
road: 0x999999,
sun: 0xffffff, sun: 0xffffff,
ambient: 0x444444, ambient: 0x777777,
}, },
camera: { camera: {
fov: 60, fov: 45, // Narrower FOV for better city look
near: 0.1, near: 1,
far: 20000, far: 20000,
initialPos: { x: 500, y: 400, z: 400 } initialPos: { x: 0, y: 600, z: 800 }
}, },
shadows: { shadows: {
enabled: true, enabled: true,
mapSize: 4096, mapSize: 4096,
areaSize: 2000, // Size of the shadow camera view areaSize: 1500,
bias: -0.0005 bias: -0.0001
}, },
dataUrl: './city_data.json' dataUrl: './city_data.json'
}; };
// Global variables for core components let scene, camera, renderer, controls, sunLight;
let scene, camera, renderer, controls;
let sunLight; // Needs to be global to update position in render loop
// ========================================== // ==========================================
// 2. Initialization Logic // 2. Initialization
// ========================================== // ==========================================
function init() { function init() {
// Setup core Three.js components
setupScene(); setupScene();
setupLighting(); setupLighting();
createGround(); createGround();
// Initialize controls
setupControls(); setupControls();
// Load external data
loadCityData(); loadCityData();
// Start event listeners and loop
window.addEventListener('resize', onWindowResize); window.addEventListener('resize', onWindowResize);
animate(); animate();
} }
/**
* Sets up the Scene, Camera, and Renderer.
*/
function setupScene() { function setupScene() {
scene = new THREE.Scene(); scene = new THREE.Scene();
scene.background = new THREE.Color(SETTINGS.colors.background); scene.background = new THREE.Color(SETTINGS.colors.background);
// Fog blends the floor into the background at distance for depth perception
scene.fog = new THREE.FogExp2(SETTINGS.colors.background, 0.0001); scene.fog = new THREE.FogExp2(SETTINGS.colors.background, 0.0001);
camera = new THREE.PerspectiveCamera( camera = new THREE.PerspectiveCamera(
@@ -75,7 +67,7 @@ function setupScene() {
renderer = new THREE.WebGLRenderer({ renderer = new THREE.WebGLRenderer({
antialias: true, antialias: true,
logarithmicDepthBuffer: true // Prevents z-fighting on large scale scenes logarithmicDepthBuffer: true
}); });
renderer.setSize(window.innerWidth, window.innerHeight); renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = SETTINGS.shadows.enabled; renderer.shadowMap.enabled = SETTINGS.shadows.enabled;
@@ -89,24 +81,14 @@ function setupScene() {
* Configures shadow properties for the directional light. * Configures shadow properties for the directional light.
*/ */
function setupLighting() { function setupLighting() {
// Ambient light for general illumination (so shadows aren't pitch black) const ambient = new THREE.HemisphereLight(SETTINGS.colors.sun, SETTINGS.colors.ground, 0.6);
const hemiLight = new THREE.HemisphereLight( scene.add(ambient);
SETTINGS.colors.sun,
SETTINGS.colors.ambient,
0.6
);
scene.add(hemiLight);
// Directional light acting as the Sun sunLight = new THREE.DirectionalLight(SETTINGS.colors.sun, 2.0);
sunLight = new THREE.DirectionalLight(SETTINGS.colors.sun, 1.5); sunLight.position.set(100, 500, 200);
sunLight.position.set(200, 400, 100);
sunLight.castShadow = true; sunLight.castShadow = true;
// Optimize shadow quality sunLight.shadow.mapSize.set(SETTINGS.shadows.mapSize, SETTINGS.shadows.mapSize);
sunLight.shadow.mapSize.width = SETTINGS.shadows.mapSize;
sunLight.shadow.mapSize.height = SETTINGS.shadows.mapSize;
// Define the box in which shadows are calculated
const d = SETTINGS.shadows.areaSize; const d = SETTINGS.shadows.areaSize;
sunLight.shadow.camera.left = -d; sunLight.shadow.camera.left = -d;
sunLight.shadow.camera.right = d; sunLight.shadow.camera.right = d;
@@ -114,93 +96,185 @@ function setupLighting() {
sunLight.shadow.camera.bottom = -d; sunLight.shadow.camera.bottom = -d;
sunLight.shadow.camera.near = 0.5; sunLight.shadow.camera.near = 0.5;
sunLight.shadow.camera.far = 4000; sunLight.shadow.camera.far = 4500;
sunLight.shadow.bias = SETTINGS.shadows.bias; // Reduces shadow artifacts (striping) sunLight.shadow.bias = SETTINGS.shadows.bias;
scene.add(sunLight); scene.add(sunLight);
} }
function setupControls() { function setupControls() {
controls = new MapControls(camera, renderer.domElement); controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true; // Adds weight to movement for smoother feel controls.enableDamping = true;
controls.dampingFactor = 0.05; controls.dampingFactor = 0.05;
controls.target.set(-100, 0, 200); controls.minDistance = 50;
controls.maxDistance = 3000;
controls.maxPolarAngle = Math.PI / 2 - 0.1; // Don't go below ground
} }
// ==========================================
// 3. Scene Content Generation
// ==========================================
function createGround() { function createGround() {
const geometry = new THREE.PlaneGeometry(5000, 5000); const geom = new THREE.PlaneGeometry(10000, 10000);
const material = new THREE.MeshStandardMaterial({ const mat = new THREE.MeshLambertMaterial({ color: SETTINGS.colors.ground });
color: SETTINGS.colors.ground const mesh = new THREE.Mesh(geom, mat);
}); mesh.rotation.x = -Math.PI / 2;
mesh.position.y = -0.5; // Slightly below features
const ground = new THREE.Mesh(geometry, material); mesh.receiveShadow = true;
ground.rotation.x = -Math.PI / 2; // Rotate to lie flat scene.add(mesh);
ground.receiveShadow = true;
scene.add(ground);
} }
// ==========================================
// 3. Data Processing & Mesh Generation
// ==========================================
function loadCityData() { function loadCityData() {
fetch(SETTINGS.dataUrl) fetch(SETTINGS.dataUrl)
.then(res => { .then(res => res.json())
if (!res.ok) throw new Error(`HTTP Error: ${res.status}`); .then(data => {
return res.json(); console.log("City data loaded. Generating geometry...");
generateCity(data);
}) })
.then(buildingData => { .catch(err => console.error("Error loading city data:", err));
generateCityMesh(buildingData); }
})
.catch(error => { function generateCity(data) {
console.error("Failed to load city data:", error); // 1. Water (Flat Polygons)
// Optional: Add UI feedback here if (data.water && data.water.length) {
}); createPolygonLayer(data.water, SETTINGS.colors.water, 0, 0.1);
}
// 2. Parks (Flat Polygons)
if (data.parks && data.parks.length) {
createPolygonLayer(data.parks, SETTINGS.colors.park, 0, 0.2);
}
// 3. Roads (Lines)
if (data.roads && data.roads.length) {
createRoadLayer(data.roads, SETTINGS.colors.road, 0.3);
}
// 4. Buildings (Extruded Polygons)
if (data.buildings && data.buildings.length) {
createBuildingLayer(data.buildings, SETTINGS.colors.building);
}
} }
/** /**
* efficiently renders thousands of buildings using InstancedMesh. * Creates flat meshes for things like water or parks.
* @param {Array} data - Array of arrays [x, z, width, depth, height] * Uses BufferGeometryUtils to merge them into one draw call.
*/ */
function generateCityMesh(data) { function createPolygonLayer(items, color, height, yOffset) {
if (!data || data.length === 0) return; const geometries = [];
// Create a single geometry template items.forEach(item => {
const geometry = new THREE.BoxGeometry(1, 1, 1); const shape = createShapeFromPoints(item.shape);
// Shift pivot point to bottom of box so scaling grows upwards const geometry = new THREE.ShapeGeometry(shape);
geometry.translate(0, 0.5, 0);
// Rotate to lie flat on XZ plane
geometry.rotateX(-Math.PI / 2);
geometry.translate(0, yOffset, 0); // Lift slightly to avoid z-fighting
geometries.push(geometry);
});
if (geometries.length === 0) return;
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
const material = new THREE.MeshLambertMaterial({ color: color, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(mergedGeometry, material);
mesh.receiveShadow = true;
scene.add(mesh);
}
/**
* Creates extruded meshes for buildings.
*/
function createBuildingLayer(buildings, color) {
const geometries = [];
buildings.forEach(b => {
const shape = createShapeFromPoints(b.shape);
// Extrude Settings
const extrudeSettings = {
depth: b.height,
bevelEnabled: false,
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
geometry.rotateX(-Math.PI / 2);
geometries.push(geometry);
});
if (geometries.length === 0) return;
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
// Center the geometry to optimize bounding sphere calculations
mergedGeometry.computeBoundingSphere();
const material = new THREE.MeshStandardMaterial({ const material = new THREE.MeshStandardMaterial({
color: SETTINGS.colors.building, color: color,
roughness: 0.5, roughness: 0.6,
metalness: 0.1 metalness: 0.1
}); });
const instancedMesh = new THREE.InstancedMesh(geometry, material, data.length); const mesh = new THREE.Mesh(mergedGeometry, material);
instancedMesh.castShadow = true; mesh.castShadow = true;
instancedMesh.receiveShadow = true; mesh.receiveShadow = true;
scene.add(mesh);
}
// Disable frustum culling to prevent flickering if the bounding sphere /**
// calculation is off for the entire group. Re-enable if performance is an issue. * Creates simple lines for roads.
instancedMesh.frustumCulled = false; * Note: THREE.Line doesn't cast shadows easily, but it's performant.
*/
function createRoadLayer(roads, color, yOffset) {
const positions = [];
const transformHelper = new THREE.Object3D(); roads.forEach(road => {
const path = road.path;
data.forEach((building, index) => { for (let i = 0; i < path.length - 1; i++) {
const [posX, posZ, width, depth, height] = building; positions.push(path[i][0], path[i][1], 0);
positions.push(path[i + 1][0], path[i + 1][1], 0);
transformHelper.position.set(posX, 0, posZ); }
transformHelper.scale.set(width, height, depth);
transformHelper.updateMatrix();
instancedMesh.setMatrixAt(index, transformHelper.matrix);
}); });
scene.add(instancedMesh); const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.rotateX(-Math.PI / 2);
geometry.translate(0, yOffset, 0);
const material = new THREE.LineBasicMaterial({
color: color,
opacity: 0.8,
transparent: true
});
const lineSegments = new THREE.LineSegments(geometry, material);
scene.add(lineSegments);
}
/**
* Helper to convert array of [x,z] points into a THREE.Shape
*/
function createShapeFromPoints(points) {
const shape = new THREE.Shape();
if (!points || points.length === 0) return shape;
// First point
shape.moveTo(points[0][0], points[0][1]);
// Subsequent points
for (let i = 1; i < points.length; i++) {
shape.lineTo(points[i][0], points[i][1]);
}
return shape;
} }
// ========================================== // ==========================================
// 4. Animation & Event Loop // 4. Animation Loop
// ========================================== // ==========================================
function onWindowResize() { function onWindowResize() {
@@ -210,13 +284,10 @@ function onWindowResize() {
} }
function updateSunPosition() { function updateSunPosition() {
// Move the light with the camera to simulate a "sun" that always
// casts high-res shadows near the player (Pseudo-Cascaded Shadow Map)
if (sunLight && camera) { if (sunLight && camera) {
// Keep shadow map centered around camera
sunLight.position.x = camera.position.x + 100; sunLight.position.x = camera.position.x + 100;
sunLight.position.z = camera.position.z + 100; sunLight.position.z = camera.position.z + 200;
// Ensure the light points at the camera's ground position
sunLight.target.position.set(camera.position.x, 0, camera.position.z); sunLight.target.position.set(camera.position.x, 0, camera.position.z);
sunLight.target.updateMatrixWorld(); sunLight.target.updateMatrixWorld();
} }
@@ -224,12 +295,10 @@ function updateSunPosition() {
function animate() { function animate() {
requestAnimationFrame(animate); requestAnimationFrame(animate);
controls.update(); controls.update();
updateSunPosition(); updateSunPosition();
renderer.render(scene, camera); renderer.render(scene, camera);
} }
// Start the application // Start
init(); init();