voxcity 0.4.6__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of voxcity might be problematic. Click here for more details.

@@ -0,0 +1,556 @@
1
+ import requests
2
+ import zipfile
3
+ import io
4
+ import os
5
+ import numpy as np
6
+ from urllib.parse import urlparse
7
+ from pathlib import Path
8
+ import lxml.etree as ET
9
+ import geopandas as gpd
10
+ from shapely.geometry import Polygon, Point, MultiPolygon
11
+ import pandas as pd
12
+ from tqdm import tqdm
13
+
14
+ def download_and_extract_zip(url, extract_to='.'):
15
+ """
16
+ Download and extract a zip file from a URL
17
+ """
18
+ # Send a GET request to the URL
19
+ response = requests.get(url)
20
+
21
+ # Check if the request was successful
22
+ if response.status_code == 200:
23
+ # Extract the base name of the zip file from the URL
24
+ parsed_url = urlparse(url)
25
+ zip_filename = os.path.basename(parsed_url.path)
26
+ folder_name = os.path.splitext(zip_filename)[0] # Remove the .zip extension
27
+
28
+ # Create the extraction directory
29
+ extraction_path = os.path.join(extract_to, folder_name)
30
+ os.makedirs(extraction_path, exist_ok=True)
31
+
32
+ # Create a BytesIO object from the response content
33
+ zip_file = io.BytesIO(response.content)
34
+
35
+ # Open the zip file
36
+ with zipfile.ZipFile(zip_file) as z:
37
+ # Extract all the contents of the zip file to the specified directory
38
+ z.extractall(extraction_path)
39
+ print(f"Extracted to {extraction_path}")
40
+ else:
41
+ print(f"Failed to download the file. Status code: {response.status_code}")
42
+
43
+ return extraction_path, folder_name
44
+
45
+
46
+ def validate_coords(coords):
47
+ """
48
+ Validate that coordinates are not infinite or NaN
49
+ """
50
+ return all(not np.isinf(x) and not np.isnan(x) for coord in coords for x in coord)
51
+
52
+
53
+ def swap_coordinates(polygon):
54
+ """
55
+ Swap coordinates in a polygon (lat/lon to lon/lat or vice versa)
56
+ """
57
+ if isinstance(polygon, MultiPolygon):
58
+ # Handle MultiPolygon objects
59
+ new_polygons = []
60
+ for geom in polygon.geoms:
61
+ coords = list(geom.exterior.coords)
62
+ swapped_coords = [(y, x) for x, y in coords]
63
+ new_polygons.append(Polygon(swapped_coords))
64
+ return MultiPolygon(new_polygons)
65
+ else:
66
+ # Handle regular Polygon objects
67
+ coords = list(polygon.exterior.coords)
68
+ swapped_coords = [(y, x) for x, y in coords]
69
+ return Polygon(swapped_coords)
70
+
71
+
72
+ def extract_terrain_info(file_path, namespaces):
73
+ """
74
+ Extract terrain elevation information from a CityGML file
75
+ """
76
+ try:
77
+ tree = ET.parse(file_path)
78
+ root = tree.getroot()
79
+
80
+ terrain_elements = []
81
+
82
+ # Look for Relief features in the CityGML file
83
+ for relief in root.findall('.//dem:ReliefFeature', namespaces):
84
+ relief_id = relief.get('{http://www.opengis.net/gml}id')
85
+
86
+ # Extract TIN Relief components
87
+ for tin in relief.findall('.//dem:TINRelief', namespaces):
88
+ tin_id = tin.get('{http://www.opengis.net/gml}id')
89
+
90
+ # Extract triangulated surface
91
+ triangles = tin.findall('.//gml:Triangle', namespaces)
92
+ for i, triangle in enumerate(triangles):
93
+ # Extract the coordinates of each triangle
94
+ pos_lists = triangle.findall('.//gml:posList', namespaces)
95
+
96
+ for pos_list in pos_lists:
97
+ try:
98
+ # Process the coordinates
99
+ coords_text = pos_list.text.strip().split()
100
+ coords = []
101
+ elevations = []
102
+
103
+ # Process coordinates in triplets (x, y, z)
104
+ for j in range(0, len(coords_text), 3):
105
+ if j + 2 < len(coords_text):
106
+ x = float(coords_text[j])
107
+ y = float(coords_text[j + 1])
108
+ z = float(coords_text[j + 2]) # Elevation
109
+
110
+ if not np.isinf(x) and not np.isinf(y) and not np.isinf(z):
111
+ coords.append((x, y))
112
+ elevations.append(z)
113
+
114
+ if len(coords) >= 3 and validate_coords(coords):
115
+ polygon = Polygon(coords)
116
+ if polygon.is_valid:
117
+ # Calculate centroid for point representation
118
+ centroid = polygon.centroid
119
+ avg_elevation = np.mean(elevations)
120
+
121
+ terrain_elements.append({
122
+ 'relief_id': relief_id,
123
+ 'tin_id': tin_id,
124
+ 'triangle_id': f"{tin_id}_tri_{i}",
125
+ 'elevation': avg_elevation,
126
+ 'geometry': centroid,
127
+ 'polygon': polygon,
128
+ 'source_file': Path(file_path).name
129
+ })
130
+ except (ValueError, IndexError) as e:
131
+ print(f"Error processing triangle in relief {relief_id}: {e}")
132
+ continue
133
+
134
+ # Extract breaklines
135
+ for breakline in relief.findall('.//dem:breaklines', namespaces):
136
+ for line in breakline.findall('.//gml:LineString', namespaces):
137
+ line_id = line.get('{http://www.opengis.net/gml}id')
138
+ pos_list = line.find('.//gml:posList', namespaces)
139
+
140
+ if pos_list is not None:
141
+ try:
142
+ coords_text = pos_list.text.strip().split()
143
+ points = []
144
+ elevations = []
145
+
146
+ for j in range(0, len(coords_text), 3):
147
+ if j + 2 < len(coords_text):
148
+ x = float(coords_text[j])
149
+ y = float(coords_text[j + 1])
150
+ z = float(coords_text[j + 2])
151
+
152
+ if not np.isinf(x) and not np.isinf(y) and not np.isinf(z):
153
+ points.append(Point(x, y))
154
+ elevations.append(z)
155
+
156
+ for k, point in enumerate(points):
157
+ if point.is_valid:
158
+ terrain_elements.append({
159
+ 'relief_id': relief_id,
160
+ 'breakline_id': line_id,
161
+ 'point_id': f"{line_id}_pt_{k}",
162
+ 'elevation': elevations[k],
163
+ 'geometry': point,
164
+ 'polygon': None,
165
+ 'source_file': Path(file_path).name
166
+ })
167
+ except (ValueError, IndexError) as e:
168
+ print(f"Error processing breakline {line_id}: {e}")
169
+ continue
170
+
171
+ # Extract mass points
172
+ for mass_point in relief.findall('.//dem:massPoint', namespaces):
173
+ for point in mass_point.findall('.//gml:Point', namespaces):
174
+ point_id = point.get('{http://www.opengis.net/gml}id')
175
+ pos = point.find('.//gml:pos', namespaces)
176
+
177
+ if pos is not None:
178
+ try:
179
+ coords = pos.text.strip().split()
180
+ if len(coords) >= 3:
181
+ x = float(coords[0])
182
+ y = float(coords[1])
183
+ z = float(coords[2])
184
+
185
+ if not np.isinf(x) and not np.isinf(y) and not np.isinf(z):
186
+ point_geom = Point(x, y)
187
+ if point_geom.is_valid:
188
+ terrain_elements.append({
189
+ 'relief_id': relief_id,
190
+ 'mass_point_id': point_id,
191
+ 'elevation': z,
192
+ 'geometry': point_geom,
193
+ 'polygon': None,
194
+ 'source_file': Path(file_path).name
195
+ })
196
+ except (ValueError, IndexError) as e:
197
+ print(f"Error processing mass point {point_id}: {e}")
198
+ continue
199
+
200
+ print(f"Extracted {len(terrain_elements)} terrain elements from {Path(file_path).name}")
201
+ return terrain_elements
202
+
203
+ except Exception as e:
204
+ print(f"Error processing terrain in file {Path(file_path).name}: {e}")
205
+ return []
206
+
207
+ def extract_vegetation_info(file_path, namespaces):
208
+ """
209
+ Extract vegetation features (PlantCover, SolitaryVegetationObject)
210
+ from a CityGML file, handling LOD0..LOD3 geometry and MultiSurface/CompositeSurface.
211
+ """
212
+ vegetation_elements = []
213
+
214
+ try:
215
+ tree = ET.parse(file_path)
216
+ root = tree.getroot()
217
+ except Exception as e:
218
+ print(f"Error parsing CityGML file {Path(file_path).name}: {e}")
219
+ return vegetation_elements
220
+
221
+ # ----------------------------------------------------------------------------
222
+ # Helper: parse all polygons from a <gml:MultiSurface> or <veg:lodXMultiSurface>
223
+ # ----------------------------------------------------------------------------
224
+ def parse_lod_multisurface(lod_elem):
225
+ """Return a Shapely (Multi)Polygon from gml:Polygon elements under lod_elem."""
226
+ polygons = []
227
+ # Find all Polygons (including nested in CompositeSurface)
228
+ for poly_node in lod_elem.findall('.//gml:Polygon', namespaces):
229
+ ring_node = poly_node.find('.//gml:exterior//gml:LinearRing//gml:posList', namespaces)
230
+ if ring_node is None or ring_node.text is None:
231
+ continue
232
+
233
+ # Parse coordinate text
234
+ coords_text = ring_node.text.strip().split()
235
+ coords = []
236
+ # Typically posList is in triplets: (x, y, z)
237
+ for i in range(0, len(coords_text), 3):
238
+ try:
239
+ x = float(coords_text[i])
240
+ y = float(coords_text[i+1])
241
+ # z = float(coords_text[i+2]) # if you want z
242
+ coords.append((x, y))
243
+ except:
244
+ # Skip any parse error or incomplete coordinate
245
+ pass
246
+
247
+ if len(coords) >= 3:
248
+ polygon = Polygon(coords)
249
+ if polygon.is_valid:
250
+ polygons.append(polygon)
251
+
252
+ if not polygons:
253
+ return None
254
+ elif len(polygons) == 1:
255
+ return polygons[0]
256
+ else:
257
+ return MultiPolygon(polygons)
258
+
259
+ # ----------------------------------------------------------------------------
260
+ # Helper: retrieve geometry from all LOD tags
261
+ # ----------------------------------------------------------------------------
262
+ def get_veg_geometry(veg_elem):
263
+ """
264
+ Search for geometry under lod0Geometry, lod1Geometry, lod2Geometry,
265
+ lod3Geometry, lod4Geometry, as well as lod0MultiSurface ... lod3MultiSurface, etc.
266
+ Return a Shapely geometry (Polygon or MultiPolygon) if found.
267
+ """
268
+ geometry_lods = [
269
+ "lod0Geometry", "lod1Geometry", "lod2Geometry", "lod3Geometry", "lod4Geometry",
270
+ "lod0MultiSurface", "lod1MultiSurface", "lod2MultiSurface", "lod3MultiSurface", "lod4MultiSurface"
271
+ ]
272
+
273
+ for lod_tag in geometry_lods:
274
+ # e.g. .//veg:lod3Geometry
275
+ lod_elem = veg_elem.find(f'.//veg:{lod_tag}', namespaces)
276
+ if lod_elem is not None:
277
+ geom = parse_lod_multisurface(lod_elem)
278
+ if geom is not None:
279
+ return geom
280
+
281
+ return None
282
+
283
+ # ----------------------------------------------------------------------------
284
+ # 1) PlantCover
285
+ # ----------------------------------------------------------------------------
286
+ for plant_cover in root.findall('.//veg:PlantCover', namespaces):
287
+ cover_id = plant_cover.get('{http://www.opengis.net/gml}id')
288
+ # averageHeight (if present)
289
+ avg_height_elem = plant_cover.find('.//veg:averageHeight', namespaces)
290
+ if avg_height_elem is not None and avg_height_elem.text:
291
+ try:
292
+ vegetation_height = float(avg_height_elem.text)
293
+ except:
294
+ vegetation_height = None
295
+ else:
296
+ vegetation_height = None
297
+
298
+ # parse geometry from LOD0..LOD3
299
+ geometry = get_veg_geometry(plant_cover)
300
+
301
+ if geometry is not None and not geometry.is_empty:
302
+ vegetation_elements.append({
303
+ 'object_type': 'PlantCover',
304
+ 'vegetation_id': cover_id,
305
+ 'height': vegetation_height,
306
+ 'geometry': geometry,
307
+ 'source_file': Path(file_path).name
308
+ })
309
+
310
+ # ----------------------------------------------------------------------------
311
+ # 2) SolitaryVegetationObject
312
+ # ----------------------------------------------------------------------------
313
+ for solitary in root.findall('.//veg:SolitaryVegetationObject', namespaces):
314
+ veg_id = solitary.get('{http://www.opengis.net/gml}id')
315
+ height_elem = solitary.find('.//veg:height', namespaces)
316
+ if height_elem is not None and height_elem.text:
317
+ try:
318
+ veg_height = float(height_elem.text)
319
+ except:
320
+ veg_height = None
321
+ else:
322
+ veg_height = None
323
+
324
+ geometry = get_veg_geometry(solitary)
325
+ if geometry is not None and not geometry.is_empty:
326
+ vegetation_elements.append({
327
+ 'object_type': 'SolitaryVegetationObject',
328
+ 'vegetation_id': veg_id,
329
+ 'height': veg_height,
330
+ 'geometry': geometry,
331
+ 'source_file': Path(file_path).name
332
+ })
333
+
334
+ if vegetation_elements:
335
+ print(f"Extracted {len(vegetation_elements)} vegetation objects from {Path(file_path).name}")
336
+ return vegetation_elements
337
+
338
+ def process_citygml_file(file_path):
339
+ """
340
+ Process a CityGML file to extract building, terrain, and vegetation information
341
+ """
342
+ buildings = []
343
+ terrain_elements = []
344
+ vegetation_elements = []
345
+
346
+ # Namespaces (now includes 'veg')
347
+ namespaces = {
348
+ 'core': 'http://www.opengis.net/citygml/2.0',
349
+ 'bldg': 'http://www.opengis.net/citygml/building/2.0',
350
+ 'gml': 'http://www.opengis.net/gml',
351
+ 'uro': 'https://www.geospatial.jp/iur/uro/3.0',
352
+ 'dem': 'http://www.opengis.net/citygml/relief/2.0',
353
+ 'veg': 'http://www.opengis.net/citygml/vegetation/2.0'
354
+ }
355
+
356
+ try:
357
+ # Parse the file once at the start (optional; if you want to share 'root' among sub-extractors)
358
+ tree = ET.parse(file_path)
359
+ root = tree.getroot()
360
+
361
+ # --- Extract Building Info (existing approach) ---
362
+ for building in root.findall('.//bldg:Building', namespaces):
363
+ building_id = building.get('{http://www.opengis.net/gml}id')
364
+ measured_height = building.find('.//bldg:measuredHeight', namespaces)
365
+ height = float(measured_height.text) if measured_height is not None else None
366
+
367
+ # Extract the footprint (LOD0)
368
+ lod0_roof_edge = building.find('.//bldg:lod0RoofEdge//gml:posList', namespaces)
369
+ if lod0_roof_edge is not None:
370
+ try:
371
+ pos_list = lod0_roof_edge.text.strip().split()
372
+ coords = []
373
+ for i in range(0, len(pos_list), 3):
374
+ if i + 2 < len(pos_list):
375
+ lon = float(pos_list[i])
376
+ lat = float(pos_list[i + 1])
377
+ elevation = float(pos_list[i + 2]) # z value
378
+ if not np.isinf(lon) and not np.isinf(lat):
379
+ coords.append((lon, lat))
380
+
381
+ if len(coords) >= 3 and validate_coords(coords):
382
+ polygon = Polygon(coords)
383
+ if polygon.is_valid:
384
+ buildings.append({
385
+ 'building_id': building_id,
386
+ 'height': height,
387
+ 'ground_elevation': elevation, # Add ground elevation if relevant
388
+ 'geometry': polygon,
389
+ 'source_file': Path(file_path).name
390
+ })
391
+ except (ValueError, IndexError) as e:
392
+ print(f"Error processing building {building_id} in file {Path(file_path).name}: {e}")
393
+
394
+ # --- Extract Terrain Info (existing function) ---
395
+ terrain_elements = extract_terrain_info(file_path, namespaces)
396
+
397
+ # --- Extract Vegetation Info (new function) ---
398
+ vegetation_elements = extract_vegetation_info(file_path, namespaces)
399
+
400
+ print(f"Processed {Path(file_path).name}: "
401
+ f"{len(buildings)} buildings, {len(terrain_elements)} terrain elements, "
402
+ f"{len(vegetation_elements)} vegetation objects")
403
+
404
+ except Exception as e:
405
+ print(f"Error processing file {Path(file_path).name}: {e}")
406
+
407
+ return buildings, terrain_elements, vegetation_elements
408
+
409
+ def load_plateau_with_terrain(url, base_dir):
410
+ """
411
+ Load PLATEAU data, extracting Buildings, Terrain, and Vegetation data from CityGML.
412
+ """
413
+ # 1) Download & unzip
414
+ citygml_path, foldername = download_and_extract_zip(url, extract_to=base_dir)
415
+
416
+ # 2) Identify CityGML files in typical folder structure
417
+ try:
418
+ citygml_dir = os.path.join(citygml_path, 'udx')
419
+ if not os.path.exists(citygml_dir):
420
+ citygml_dir = os.path.join(citygml_path, foldername, 'udx')
421
+
422
+ bldg_dir = os.path.join(citygml_dir, 'bldg')
423
+ dem_dir = os.path.join(citygml_dir, 'dem')
424
+
425
+ # NEW: check for vegetation folder
426
+ veg_dir = os.path.join(citygml_dir, 'veg')
427
+
428
+ citygml_files = []
429
+
430
+ # If there's a building folder, gather .gml from there
431
+ if os.path.exists(bldg_dir):
432
+ citygml_files += [
433
+ os.path.join(bldg_dir, f) for f in os.listdir(bldg_dir) if f.endswith('.gml')
434
+ ]
435
+ else:
436
+ # If no 'bldg' folder, look directly in 'udx'
437
+ citygml_files += [
438
+ os.path.join(citygml_dir, f) for f in os.listdir(citygml_dir) if f.endswith('.gml')
439
+ ]
440
+
441
+ # Also gather DEM .gml (terrain)
442
+ if os.path.exists(dem_dir):
443
+ citygml_files += [
444
+ os.path.join(dem_dir, f) for f in os.listdir(dem_dir) if f.endswith('.gml')
445
+ ]
446
+
447
+ # ADD THIS: gather VEG .gml (vegetation)
448
+ if os.path.exists(veg_dir):
449
+ citygml_files += [
450
+ os.path.join(veg_dir, f) for f in os.listdir(veg_dir) if f.endswith('.gml')
451
+ ]
452
+
453
+ total_files = len(citygml_files)
454
+ print(f"Found {total_files} CityGML files to process")
455
+
456
+ except Exception as e:
457
+ print(f"Error finding CityGML files: {e}")
458
+ return None, None, None
459
+
460
+ all_buildings = []
461
+ all_terrain = []
462
+ all_vegetation = []
463
+
464
+ # 3) Process each CityGML
465
+ for file_path in tqdm(citygml_files, desc="Processing CityGML files"):
466
+ buildings, terrain_elements, vegetation_elements = process_citygml_file(file_path)
467
+ all_buildings.extend(buildings)
468
+ all_terrain.extend(terrain_elements)
469
+ all_vegetation.extend(vegetation_elements)
470
+
471
+ # 4) Create GeoDataFrame for Buildings
472
+ if all_buildings:
473
+ gdf_buildings = gpd.GeoDataFrame(all_buildings, geometry='geometry')
474
+ gdf_buildings.set_crs(epsg=6697, inplace=True)
475
+
476
+ # Swap coords from (lon, lat) to (lat, lon) if needed
477
+ swapped_geometries = [swap_coordinates(geom) for geom in gdf_buildings.geometry]
478
+ gdf_buildings_swapped = gpd.GeoDataFrame(
479
+ {
480
+ 'building_id': gdf_buildings['building_id'],
481
+ 'height': gdf_buildings['height'],
482
+ 'ground_elevation': gdf_buildings['ground_elevation'],
483
+ 'source_file': gdf_buildings['source_file'],
484
+ 'geometry': swapped_geometries
485
+ },
486
+ crs='EPSG:6697'
487
+ )
488
+
489
+ # Save
490
+ gdf_buildings_swapped['id'] = gdf_buildings_swapped.index
491
+ gdf_buildings_swapped.to_file('all_buildings_with_elevation.geojson', driver='GeoJSON')
492
+ print(f"\nBuildings saved to all_buildings_with_elevation.geojson")
493
+ else:
494
+ gdf_buildings_swapped = None
495
+
496
+ # 5) Create GeoDataFrame for Terrain
497
+ if all_terrain:
498
+ gdf_terrain = gpd.GeoDataFrame(all_terrain, geometry='geometry')
499
+ gdf_terrain.set_crs(epsg=6697, inplace=True)
500
+
501
+ swapped_geometries = []
502
+ for geom in gdf_terrain.geometry:
503
+ if isinstance(geom, (Polygon, MultiPolygon)):
504
+ swapped_geometries.append(swap_coordinates(geom))
505
+ elif isinstance(geom, Point):
506
+ swapped_geometries.append(Point(geom.y, geom.x))
507
+ else:
508
+ swapped_geometries.append(geom)
509
+
510
+ terrain_data = {
511
+ 'relief_id': gdf_terrain.get('relief_id', ''),
512
+ 'tin_id': gdf_terrain.get('tin_id', ''),
513
+ 'triangle_id': gdf_terrain.get('triangle_id', ''),
514
+ 'breakline_id': gdf_terrain.get('breakline_id', ''),
515
+ 'mass_point_id': gdf_terrain.get('mass_point_id', ''),
516
+ 'point_id': gdf_terrain.get('point_id', ''),
517
+ 'elevation': gdf_terrain['elevation'],
518
+ 'source_file': gdf_terrain['source_file'],
519
+ 'geometry': swapped_geometries
520
+ }
521
+
522
+ gdf_terrain_swapped = gpd.GeoDataFrame(terrain_data, geometry='geometry', crs='EPSG:6697')
523
+ gdf_terrain_swapped.to_file('terrain_elevation.geojson', driver='GeoJSON')
524
+ print(f"Terrain saved to terrain_elevation.geojson")
525
+ else:
526
+ gdf_terrain_swapped = None
527
+
528
+ # 6) Create GeoDataFrame for Vegetation
529
+ if all_vegetation:
530
+ gdf_veg = gpd.GeoDataFrame(all_vegetation, geometry='geometry')
531
+ gdf_veg.set_crs(epsg=6697, inplace=True)
532
+
533
+ swapped_geometries = []
534
+ for geom in gdf_veg.geometry:
535
+ if isinstance(geom, (Polygon, MultiPolygon)):
536
+ swapped_geometries.append(swap_coordinates(geom))
537
+ elif isinstance(geom, Point):
538
+ swapped_geometries.append(Point(geom.y, geom.x))
539
+ else:
540
+ swapped_geometries.append(geom)
541
+
542
+ vegetation_data = {
543
+ 'object_type': gdf_veg.get('object_type', ''),
544
+ 'vegetation_id': gdf_veg.get('vegetation_id', ''),
545
+ 'height': gdf_veg.get('height', None),
546
+ 'avg_elevation': gdf_veg.get('avg_elevation', None), # Use .get() with a default
547
+ 'source_file': gdf_veg.get('source_file', ''),
548
+ 'geometry': swapped_geometries
549
+ }
550
+ gdf_vegetation_swapped = gpd.GeoDataFrame(vegetation_data, geometry='geometry', crs='EPSG:6697')
551
+ gdf_vegetation_swapped.to_file('vegetation_elevation.geojson', driver='GeoJSON')
552
+ print(f"Vegetation saved to vegetation_elevation.geojson")
553
+ else:
554
+ gdf_vegetation_swapped = None
555
+
556
+ return gdf_buildings_swapped, gdf_terrain_swapped, gdf_vegetation_swapped
voxcity/generator.py CHANGED
@@ -24,6 +24,7 @@ from .downloader.oemj import save_oemj_as_geotiff
24
24
  from .downloader.omt import load_gdf_from_openmaptiles
25
25
  from .downloader.eubucco import load_gdf_from_eubucco
26
26
  from .downloader.overture import load_gdf_from_overture
27
+ from .downloader.citygml import load_plateau_with_terrain
27
28
  from .downloader.gee import (
28
29
  initialize_earth_engine,
29
30
  get_roi,
@@ -691,6 +692,125 @@ def get_voxcity(rectangle_vertices, building_source, land_cover_source, canopy_h
691
692
 
692
693
  return voxcity_grid, building_height_grid, building_min_height_grid, building_id_grid, canopy_height_grid, land_cover_grid, dem_grid, building_gdf
693
694
 
695
+ def get_voxcity_CityGML(rectangle_vertices, url_citygml, land_cover_source, canopy_height_source, meshsize, **kwargs):
696
+ """Main function to generate a complete voxel city model.
697
+
698
+ Args:
699
+ rectangle_vertices: List of coordinates defining the area of interest
700
+ building_source: Source for building height data (e.g. 'OSM', 'EUBUCCO')
701
+ land_cover_source: Source for land cover data (e.g. 'ESA', 'ESRI')
702
+ canopy_height_source: Source for tree canopy height data
703
+ dem_source: Source for digital elevation model data ('Flat' or other source)
704
+ meshsize: Size of each grid cell in meters
705
+ **kwargs: Additional keyword arguments including:
706
+ - output_dir: Directory to save output files (default: 'output')
707
+ - min_canopy_height: Minimum height threshold for tree canopy
708
+ - remove_perimeter_object: Factor to remove objects near perimeter
709
+ - mapvis: Whether to visualize grids on map
710
+ - voxelvis: Whether to visualize 3D voxel model
711
+ - voxelvis_img_save_path: Path to save 3D visualization
712
+
713
+ Returns:
714
+ tuple containing:
715
+ - voxcity_grid: 3D voxel grid of the complete city model
716
+ - building_height_grid: 2D grid of building heights
717
+ - building_min_height_grid: 2D grid of minimum building heights
718
+ - building_id_grid: 2D grid of building IDs
719
+ - canopy_height_grid: 2D grid of tree canopy heights
720
+ - land_cover_grid: 2D grid of land cover classifications
721
+ - dem_grid: 2D grid of ground elevation
722
+ - building_geojson: GeoJSON of building footprints and metadata
723
+ """
724
+ # Create output directory if it doesn't exist
725
+ output_dir = kwargs.get("output_dir", "output")
726
+ os.makedirs(output_dir, exist_ok=True)
727
+
728
+ # Remove 'output_dir' from kwargs to prevent duplication
729
+ kwargs.pop('output_dir', None)
730
+
731
+ # get all required gdfs
732
+ building_gdf, terrain_gdf, vegetation_gdf = load_plateau_with_terrain(url_citygml, base_dir=output_dir)
733
+
734
+ land_cover_grid = get_land_cover_grid(rectangle_vertices, meshsize, land_cover_source, output_dir, **kwargs)
735
+
736
+ # building_height_grid, building_min_height_grid, building_id_grid, building_gdf = get_building_height_grid(rectangle_vertices, meshsize, building_source, output_dir, **kwargs)
737
+ building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(buildings_gdf, meshsize, rectangle_vertices, **kwargs)
738
+
739
+ # Visualize grid if requested
740
+ grid_vis = kwargs.get("gridvis", True)
741
+ if grid_vis:
742
+ building_height_grid_nan = building_height_grid.copy()
743
+ building_height_grid_nan[building_height_grid_nan == 0] = np.nan
744
+ visualize_numerical_grid(np.flipud(building_height_grid_nan), meshsize, "building height (m)", cmap='viridis', label='Value')
745
+
746
+ # Save building data to GeoJSON
747
+ if not building_gdf.empty:
748
+ save_path = f"{output_dir}/building.gpkg"
749
+ building_gdf.to_file(save_path, driver='GPKG')
750
+
751
+ # Get canopy height data
752
+ if canopy_height_source == "Static":
753
+ # Create canopy height grid with same shape as land cover grid
754
+ canopy_height_grid_comp = np.zeros_like(land_cover_grid, dtype=float)
755
+
756
+ # Set default static height for trees (20 meters is a typical average tree height)
757
+ static_tree_height = kwargs.get("static_tree_height", 10.0)
758
+ tree_mask = (land_cover_grid == 4)
759
+
760
+ # Set static height for tree cells
761
+ canopy_height_grid_comp[tree_mask] = static_tree_height
762
+ else:
763
+ canopy_height_grid_comp = get_canopy_height_grid(rectangle_vertices, meshsize, canopy_height_source, output_dir, **kwargs)
764
+
765
+ canopy_height_grid = create_vegetation_height_grid_from_gdf_polygon(vegetation_gdf, meshsize, rectangle_vertices)
766
+ mask = (canopy_height_grid == 0) & (canopy_height_grid_comp != 0)
767
+ canopy_height_grid[mask] = canopy_height_grid_comp[mask]
768
+
769
+ # Handle DEM - either flat or from source
770
+ if kwargs.pop('flat_dem', None):
771
+ dem_grid = np.zeros_like(land_cover_grid)
772
+ else:
773
+ dem_grid = create_dem_grid_from_gdf_polygon(terrain_gdf, meshsize, rectangle_vertices)
774
+
775
+ # Visualize grid if requested
776
+ grid_vis = kwargs.get("gridvis", True)
777
+ if grid_vis:
778
+ visualize_numerical_grid(np.flipud(dem_grid), meshsize, title='Digital Elevation Model', cmap='terrain', label='Elevation (m)')
779
+
780
+
781
+ # Apply minimum canopy height threshold if specified
782
+ min_canopy_height = kwargs.get("min_canopy_height")
783
+ if min_canopy_height is not None:
784
+ canopy_height_grid[canopy_height_grid < kwargs["min_canopy_height"]] = 0
785
+
786
+ # Remove objects near perimeter if specified
787
+ remove_perimeter_object = kwargs.get("remove_perimeter_object")
788
+ if (remove_perimeter_object is not None) and (remove_perimeter_object > 0):
789
+ # Calculate perimeter width based on grid dimensions
790
+ w_peri = int(remove_perimeter_object * building_height_grid.shape[0] + 0.5)
791
+ h_peri = int(remove_perimeter_object * building_height_grid.shape[1] + 0.5)
792
+
793
+ # Clear canopy heights in perimeter
794
+ canopy_height_grid[:w_peri, :] = canopy_height_grid[-w_peri:, :] = canopy_height_grid[:, :h_peri] = canopy_height_grid[:, -h_peri:] = 0
795
+
796
+ # Find building IDs in perimeter regions
797
+ ids1 = np.unique(building_id_grid[:w_peri, :][building_id_grid[:w_peri, :] > 0])
798
+ ids2 = np.unique(building_id_grid[-w_peri:, :][building_id_grid[-w_peri:, :] > 0])
799
+ ids3 = np.unique(building_id_grid[:, :h_peri][building_id_grid[:, :h_peri] > 0])
800
+ ids4 = np.unique(building_id_grid[:, -h_peri:][building_id_grid[:, -h_peri:] > 0])
801
+ remove_ids = np.concatenate((ids1, ids2, ids3, ids4))
802
+
803
+ # Remove buildings in perimeter
804
+ for remove_id in remove_ids:
805
+ positions = np.where(building_id_grid == remove_id)
806
+ building_height_grid[positions] = 0
807
+ building_min_height_grid[positions] = [[] for _ in range(len(building_min_height_grid[positions]))]
808
+
809
+ # Generate 3D voxel grid
810
+ voxcity_grid = create_3d_voxel(building_height_grid, building_min_height_grid, building_id_grid, land_cover_grid, dem_grid, canopy_height_grid, meshsize, land_cover_source)
811
+
812
+ return voxcity_grid, building_height_grid, building_min_height_grid, building_id_grid, canopy_height_grid, land_cover_grid, dem_grid, building_gdf
813
+
694
814
  def replace_nan_in_nested(arr, replace_value=10.0):
695
815
  """Replace NaN values in a nested array structure with a specified value.
696
816
 
@@ -1013,4 +1013,253 @@ def grid_to_point_geodataframe(grid_ori, rectangle_vertices, meshsize):
1013
1013
  'value': values
1014
1014
  }, crs=CRS.from_epsg(4326))
1015
1015
 
1016
- return gdf
1016
+ return gdf
1017
+
1018
+ def create_vegetation_height_grid_from_gdf_polygon(veg_gdf, mesh_size, polygon):
1019
+ """
1020
+ Create a vegetation height grid from a GeoDataFrame of vegetation polygons/objects
1021
+ within the bounding box of a given polygon, at a specified mesh spacing.
1022
+ Cells that intersect one or more vegetation polygons receive the
1023
+ (by default) maximum vegetation height among intersecting polygons.
1024
+ Cells that do not intersect any vegetation are set to 0.
1025
+
1026
+ Args:
1027
+ veg_gdf (GeoDataFrame): A GeoDataFrame containing vegetation features
1028
+ (usually polygons) with a 'height' column
1029
+ (or a similarly named attribute). Must be in
1030
+ EPSG:4326 or reprojectable to it.
1031
+ mesh_size (float): Desired grid spacing in meters.
1032
+ polygon (list or Polygon):
1033
+ - If a list of (lon, lat) coords, will be converted to a shapely Polygon
1034
+ in EPSG:4326.
1035
+ - If a shapely Polygon, it must be in or reprojectable to EPSG:4326.
1036
+
1037
+ Returns:
1038
+ np.ndarray: 2D array of vegetation height values covering the bounding box
1039
+ of the polygon. The array is indexed [row, col] from top row
1040
+ (north) to bottom row (south). Cells with no intersecting
1041
+ vegetation are set to 0.
1042
+ """
1043
+ # ------------------------------------------------------------------------
1044
+ # 1. Ensure veg_gdf is in WGS84 (EPSG:4326)
1045
+ # ------------------------------------------------------------------------
1046
+ if veg_gdf.crs is None:
1047
+ warnings.warn("veg_gdf has no CRS. Assuming EPSG:4326. "
1048
+ "If this is incorrect, please set the correct CRS and re-run.")
1049
+ veg_gdf = veg_gdf.set_crs(epsg=4326)
1050
+ else:
1051
+ if veg_gdf.crs.to_epsg() != 4326:
1052
+ veg_gdf = veg_gdf.to_crs(epsg=4326)
1053
+
1054
+ # Must have a 'height' column (or change to your column name)
1055
+ if 'height' not in veg_gdf.columns:
1056
+ raise ValueError("Vegetation GeoDataFrame must have a 'height' column.")
1057
+
1058
+ # ------------------------------------------------------------------------
1059
+ # 2. Convert input polygon to shapely Polygon in WGS84
1060
+ # ------------------------------------------------------------------------
1061
+ if isinstance(polygon, list):
1062
+ poly = Polygon(polygon)
1063
+ elif isinstance(polygon, Polygon):
1064
+ poly = polygon
1065
+ else:
1066
+ raise ValueError("polygon must be a list of (lon, lat) or a shapely Polygon.")
1067
+
1068
+ # ------------------------------------------------------------------------
1069
+ # 3. Compute bounding box & grid dimensions
1070
+ # ------------------------------------------------------------------------
1071
+ left, bottom, right, top = poly.bounds
1072
+ geod = Geod(ellps="WGS84")
1073
+
1074
+ # Horizontal (width) distance in meters
1075
+ _, _, width_m = geod.inv(left, bottom, right, bottom)
1076
+ # Vertical (height) distance in meters
1077
+ _, _, height_m = geod.inv(left, bottom, left, top)
1078
+
1079
+ # Number of cells horizontally and vertically
1080
+ num_cells_x = int(width_m / mesh_size + 0.5)
1081
+ num_cells_y = int(height_m / mesh_size + 0.5)
1082
+
1083
+ if num_cells_x < 1 or num_cells_y < 1:
1084
+ warnings.warn("Polygon bounding box is smaller than mesh_size; returning empty array.")
1085
+ return np.array([])
1086
+
1087
+ # ------------------------------------------------------------------------
1088
+ # 4. Generate the grid (cell centers) covering the bounding box
1089
+ # ------------------------------------------------------------------------
1090
+ xs = np.linspace(left, right, num_cells_x)
1091
+ ys = np.linspace(top, bottom, num_cells_y) # top→bottom
1092
+ X, Y = np.meshgrid(xs, ys)
1093
+
1094
+ # Flatten these for convenience
1095
+ xs_flat = X.ravel()
1096
+ ys_flat = Y.ravel()
1097
+
1098
+ # Create cell-center points as a GeoDataFrame
1099
+ grid_points = gpd.GeoDataFrame(
1100
+ geometry=[Point(lon, lat) for lon, lat in zip(xs_flat, ys_flat)],
1101
+ crs="EPSG:4326"
1102
+ )
1103
+
1104
+ # ------------------------------------------------------------------------
1105
+ # 5. Spatial join (INTERSECTION) to find which vegetation objects each cell intersects
1106
+ # - We only fill the cell if the point is actually inside (or intersects) a vegetation polygon
1107
+ # If your data is more consistent with "contains" or "within", adjust the predicate accordingly.
1108
+ # ------------------------------------------------------------------------
1109
+ # NOTE:
1110
+ # * If your vegetation is polygons, "predicate='intersects'" or "contains"
1111
+ # can be used. Typically we check whether the cell center is inside the polygon.
1112
+ # * If your vegetation is a point layer, you might do "predicate='within'"
1113
+ # or similar. Adjust as needed.
1114
+ #
1115
+ # We'll do a left join so that unmatched cells remain in the result with NaN values.
1116
+ # Then we group by the index of the original grid_points to handle multiple intersects.
1117
+ # The 'index_right' is from the vegetation layer.
1118
+ # ------------------------------------------------------------------------
1119
+
1120
+ joined = gpd.sjoin(
1121
+ grid_points,
1122
+ veg_gdf[['height', 'geometry']],
1123
+ how='left',
1124
+ predicate='intersects'
1125
+ )
1126
+
1127
+ # Because one cell (row in grid_points) can intersect multiple polygons,
1128
+ # we need to aggregate them. We'll take the *maximum* height by default.
1129
+ joined_agg = (
1130
+ joined.groupby(joined.index) # group by the index from grid_points
1131
+ .agg({'height': 'max'}) # or 'mean' if you prefer an average
1132
+ )
1133
+
1134
+ # joined_agg is now a DataFrame with the same index as grid_points.
1135
+ # If a row didn't intersect any polygon, 'height' is NaN.
1136
+
1137
+ # ------------------------------------------------------------------------
1138
+ # 6. Build the 2D height array, initializing with zeros
1139
+ # ------------------------------------------------------------------------
1140
+ veg_grid = np.zeros((num_cells_y, num_cells_x), dtype=float)
1141
+
1142
+ # The row, col in the final array corresponds to how we built 'grid_points':
1143
+ # row = i // num_cells_x
1144
+ # col = i % num_cells_x
1145
+ for i, row_data in joined_agg.iterrows():
1146
+ if not np.isnan(row_data['height']): # Only set values for cells with vegetation
1147
+ row_idx = i // num_cells_x
1148
+ col_idx = i % num_cells_x
1149
+ veg_grid[row_idx, col_idx] = row_data['height']
1150
+
1151
+ # Result: row=0 is the top-most row, row=-1 is bottom.
1152
+ return np.flipud(veg_grid)
1153
+
1154
+ def create_dem_grid_from_gdf_polygon(terrain_gdf, mesh_size, polygon):
1155
+ """
1156
+ Create a height grid from a terrain GeoDataFrame within the bounding box
1157
+ of the given polygon, using nearest-neighbor sampling of elevations.
1158
+ Edges of the bounding box will also receive a nearest elevation,
1159
+ so there should be no NaNs around edges if data coverage is sufficient.
1160
+
1161
+ Args:
1162
+ terrain_gdf (GeoDataFrame): A GeoDataFrame containing terrain features
1163
+ (points or centroids) with an 'elevation' column.
1164
+ Must be in EPSG:4326 or reprojectable to it.
1165
+ mesh_size (float): Desired grid spacing in meters.
1166
+ polygon (list or Polygon): Polygon specifying the region of interest.
1167
+ - If list of (lon, lat), will be made into a Polygon.
1168
+ - If a shapely Polygon, must be in WGS84 (EPSG:4326)
1169
+ or reprojected to it.
1170
+
1171
+ Returns:
1172
+ np.ndarray: 2D array of height values covering the bounding box of the polygon,
1173
+ from top row (north) to bottom row (south). Any location not
1174
+ matched by terrain_gdf data remains NaN, but edges will not
1175
+ automatically be NaN if terrain coverage exists.
1176
+ """
1177
+
1178
+ # ------------------------------------------------------------------------
1179
+ # 1. Ensure terrain_gdf is in WGS84 (EPSG:4326)
1180
+ # ------------------------------------------------------------------------
1181
+ if terrain_gdf.crs is None:
1182
+ warnings.warn("terrain_gdf has no CRS. Assuming EPSG:4326. "
1183
+ "If this is incorrect, please set the correct CRS and re-run.")
1184
+ terrain_gdf = terrain_gdf.set_crs(epsg=4326)
1185
+ else:
1186
+ # Reproject if needed
1187
+ if terrain_gdf.crs.to_epsg() != 4326:
1188
+ terrain_gdf = terrain_gdf.to_crs(epsg=4326)
1189
+
1190
+ # Convert input polygon to shapely Polygon in WGS84
1191
+ if isinstance(polygon, list):
1192
+ poly = Polygon(polygon) # assume coords are (lon, lat) in EPSG:4326
1193
+ elif isinstance(polygon, Polygon):
1194
+ poly = polygon
1195
+ else:
1196
+ raise ValueError("`polygon` must be a list of (lon, lat) or a shapely Polygon.")
1197
+
1198
+ # ------------------------------------------------------------------------
1199
+ # 2. Compute bounding box and number of grid cells
1200
+ # ------------------------------------------------------------------------
1201
+ left, bottom, right, top = poly.bounds
1202
+ geod = Geod(ellps="WGS84")
1203
+
1204
+ # Geodesic distances in meters
1205
+ _, _, width_m = geod.inv(left, bottom, right, bottom)
1206
+ _, _, height_m = geod.inv(left, bottom, left, top)
1207
+
1208
+ # Number of cells in X and Y directions
1209
+ num_cells_x = int(width_m / mesh_size + 0.5)
1210
+ num_cells_y = int(height_m / mesh_size + 0.5)
1211
+
1212
+ if num_cells_x < 1 or num_cells_y < 1:
1213
+ warnings.warn("Polygon bounding box is smaller than mesh_size; returning empty array.")
1214
+ return np.array([])
1215
+
1216
+ # ------------------------------------------------------------------------
1217
+ # 3. Generate grid points covering the bounding box
1218
+ # (all points, not just inside the polygon)
1219
+ # ------------------------------------------------------------------------
1220
+ xs = np.linspace(left, right, num_cells_x)
1221
+ ys = np.linspace(top, bottom, num_cells_y) # top->bottom
1222
+ X, Y = np.meshgrid(xs, ys)
1223
+
1224
+ # Flatten for convenience
1225
+ xs_flat = X.ravel()
1226
+ ys_flat = Y.ravel()
1227
+
1228
+ # Create GeoDataFrame of all bounding-box points
1229
+ grid_points = gpd.GeoDataFrame(
1230
+ geometry=[Point(lon, lat) for lon, lat in zip(xs_flat, ys_flat)],
1231
+ crs="EPSG:4326"
1232
+ )
1233
+
1234
+ # ------------------------------------------------------------------------
1235
+ # 4. Nearest-neighbor join from terrain_gdf to grid points
1236
+ # ------------------------------------------------------------------------
1237
+ if 'elevation' not in terrain_gdf.columns:
1238
+ raise ValueError("terrain_gdf must have an 'elevation' column.")
1239
+
1240
+ # Nearest spatial join (requires GeoPandas >= 0.10)
1241
+ # This will assign each grid point the nearest terrain_gdf elevation.
1242
+ grid_points_elev = gpd.sjoin_nearest(
1243
+ grid_points,
1244
+ terrain_gdf[['elevation', 'geometry']],
1245
+ how="left",
1246
+ distance_col="dist_to_terrain"
1247
+ )
1248
+
1249
+ # ------------------------------------------------------------------------
1250
+ # 5. Build the final 2D height array
1251
+ # (rows: top->bottom, columns: left->right)
1252
+ # ------------------------------------------------------------------------
1253
+ dem_grid = np.full((num_cells_y, num_cells_x), np.nan, dtype=float)
1254
+
1255
+ # The index mapping of grid_points_elev is the same as grid_points, so:
1256
+ # row = i // num_cells_x, col = i % num_cells_x
1257
+ for i, elevation_val in zip(grid_points_elev.index, grid_points_elev['elevation']):
1258
+ row = i // num_cells_x
1259
+ col = i % num_cells_x
1260
+ dem_grid[row, col] = elevation_val # could be NaN if no data
1261
+
1262
+ # By default, row=0 is the "north/top" row, row=-1 is "south/bottom" row.
1263
+ # If you prefer the bottom row as index=0, you'd do: np.flipud(dem_grid)
1264
+
1265
+ return np.flipud(dem_grid)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: voxcity
3
- Version: 0.4.6
3
+ Version: 0.5.0
4
4
  Summary: voxcity is an easy and one-stop tool to output 3d city models for microclimate simulation by integrating multiple geospatial open-data
5
5
  Author-email: Kunihiko Fujiwara <kunihiko@nus.edu.sg>
6
6
  Maintainer-email: Kunihiko Fujiwara <kunihiko@nus.edu.sg>
@@ -53,11 +53,13 @@ Requires-Dist: joblib
53
53
  Requires-Dist: trimesh
54
54
  Requires-Dist: pyvista
55
55
  Requires-Dist: IPython
56
+ Requires-Dist: lxml
56
57
  Provides-Extra: dev
57
58
  Requires-Dist: coverage; extra == "dev"
58
59
  Requires-Dist: mypy; extra == "dev"
59
60
  Requires-Dist: pytest; extra == "dev"
60
61
  Requires-Dist: ruff; extra == "dev"
62
+ Dynamic: license-file
61
63
 
62
64
  [![PyPi version](https://img.shields.io/pypi/v/voxcity.svg)](https://pypi.python.org/pypi/voxcity)
63
65
  [![Python versions](https://img.shields.io/pypi/pyversions/voxcity.svg)](https://pypi.org/project/voxcity/)
@@ -66,6 +68,9 @@ Requires-Dist: ruff; extra == "dev"
66
68
  [![Downloads](https://pepy.tech/badge/voxcity)](https://pepy.tech/project/voxcity)
67
69
  <!-- [![License: CC BY-SA 4.0](https://licensebuttons.net/l/by-sa/4.0/80x15.png)](https://creativecommons.org/licenses/by-sa/4.0/) -->
68
70
 
71
+ <p align="center">
72
+ Tutorial preview: <a href="https://colab.research.google.com/drive/1Lofd3RawKMr6QuUsamGaF48u2MN0hfrP?usp=sharing">[Google Colab]</a>
73
+ </p>
69
74
 
70
75
  # VoxCity
71
76
 
@@ -1,6 +1,7 @@
1
1
  voxcity/__init__.py,sha256=el9v3gfybHOF_GUYPeSOqN0-vCrTW0eU1mcvi0sEfeU,252
2
- voxcity/generator.py,sha256=0RKWcWKfwFs2xcepyEDLMQdHqGhAGlpPwhNe6UJTRuI,35348
2
+ voxcity/generator.py,sha256=bHDNH2lmAq7iCLMIFMatSpI8LhM_NGNy8MjfiICZZyc,42101
3
3
  voxcity/downloader/__init__.py,sha256=OgGcGxOXF4tjcEL6DhOnt13DYPTvOigUelp5xIpTqM0,171
4
+ voxcity/downloader/citygml.py,sha256=phGxEz829mDGXEugs8rTQzARQy-_JyqfcDLMijR4DoM,24467
4
5
  voxcity/downloader/eubucco.py,sha256=XCkkdEPNuWdrnuxzL80Ext37WsgiCiZGueb-aQV5rvI,14476
5
6
  voxcity/downloader/gee.py,sha256=hEN5OvQAltORYnrlPbmYcDequ6lKLmwyTbNaCZ81Vj8,16089
6
7
  voxcity/downloader/mbfp.py,sha256=pGJuXXLRuRedlORXfg8WlgAVwmKI30jxki9t-v5NejY,3972
@@ -15,7 +16,7 @@ voxcity/exporter/magicavoxel.py,sha256=Fsv7yGRXeKmp82xcG3rOb0t_HtoqltNq2tHl08xVl
15
16
  voxcity/exporter/obj.py,sha256=0RBFPMKGRH6uNmCLIwAoYFko1bOZKtTSwg7QnoPMud0,21593
16
17
  voxcity/geoprocessor/__init_.py,sha256=JzPVhhttxBWvaZ0IGX2w7OWL5bCo_TIvpHefWeNXruA,133
17
18
  voxcity/geoprocessor/draw.py,sha256=8Em2NvazFpYfFJUqG9LofNXaxdghKLL_rNuztmPwn8Q,13911
18
- voxcity/geoprocessor/grid.py,sha256=ZZgnrvfinIGd4dLODoA3G0yru7XfR_LXph65PDRIj_Y,44428
19
+ voxcity/geoprocessor/grid.py,sha256=g1go8vvv2q4epnkZIN6CvWieSqxFWtLEYqX5CPw6vUo,56340
19
20
  voxcity/geoprocessor/mesh.py,sha256=r3cRPLgpbhjwgESBemHWWJ5pEWl2KdkRhID6mdLhios,11171
20
21
  voxcity/geoprocessor/network.py,sha256=opb_kpUCAxDd1qtrWPStqR5reYZtVe96XxazNSen7Lk,18851
21
22
  voxcity/geoprocessor/polygon.py,sha256=8Vb2AbkpKYhq1kk2hQMc-gitmUo9pFIe910v4p1vP2g,37772
@@ -29,9 +30,9 @@ voxcity/utils/lc.py,sha256=RwPd-VY3POV3gTrBhM7TubgGb9MCd3nVah_G8iUEF7k,11562
29
30
  voxcity/utils/material.py,sha256=Vt3IID5Ft54HNJcEC4zi31BCPqi_687X3CSp7rXaRVY,5907
30
31
  voxcity/utils/visualization.py,sha256=SF8W7sqvBl3sZbB5noWCY9ic2D34Gq01VZYJ9NDNZ4Y,85237
31
32
  voxcity/utils/weather.py,sha256=CFPtoqRTajwMRswswDChwQ3BW1cGsnA3orgWHgz7Ehg,26304
32
- voxcity-0.4.6.dist-info/AUTHORS.rst,sha256=m82vkI5QokEGdcHof2OxK39lf81w1P58kG9ZNNAKS9U,175
33
- voxcity-0.4.6.dist-info/LICENSE,sha256=-hGliOFiwUrUSoZiB5WF90xXGqinKyqiDI2t6hrnam8,1087
34
- voxcity-0.4.6.dist-info/METADATA,sha256=66WS05QBtRe4bEiH8XwLKTUTcMU1A83n0VrBl4nMMG4,25527
35
- voxcity-0.4.6.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
36
- voxcity-0.4.6.dist-info/top_level.txt,sha256=00b2U-LKfDllt6RL1R33MXie5MvxzUFye0NGD96t_8I,8
37
- voxcity-0.4.6.dist-info/RECORD,,
33
+ voxcity-0.5.0.dist-info/licenses/AUTHORS.rst,sha256=m82vkI5QokEGdcHof2OxK39lf81w1P58kG9ZNNAKS9U,175
34
+ voxcity-0.5.0.dist-info/licenses/LICENSE,sha256=s_jE1Df1nTPL4A_5GCGic5Zwex0CVaPKcAmSilxJPPE,1089
35
+ voxcity-0.5.0.dist-info/METADATA,sha256=sAc1kbwrv22VVTNU3wwPvj3IlOpqrnwMs1ANOdZr3v0,25733
36
+ voxcity-0.5.0.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
37
+ voxcity-0.5.0.dist-info/top_level.txt,sha256=00b2U-LKfDllt6RL1R33MXie5MvxzUFye0NGD96t_8I,8
38
+ voxcity-0.5.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (76.0.0)
2
+ Generator: setuptools (77.0.3)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2022, winstonyym
3
+ Copyright (c) 2025, kunifujiwara
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal