Imrao commited on
Commit
899033d
·
1 Parent(s): bc4c6e3
Files changed (1) hide show
  1. app.py +200 -62
app.py CHANGED
@@ -4,7 +4,6 @@ import json
4
  import tempfile
5
  import shapely.geometry
6
  import pyprt
7
- import trimesh
8
  import glob
9
  import shutil
10
  import pyproj
@@ -66,6 +65,61 @@ def get_rpk_path(filename: str):
66
  return None
67
  return path
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  @app.get("/rpks")
70
  async def list_rpks():
71
  """List available RPK files."""
@@ -202,75 +256,73 @@ async def generate_model(request: GenerateRequest):
202
  # "emitReport": True is needed to get the attributes back? No, that's get_attributes.
203
  export_options = {
204
  "outputPath": tempfile.mkdtemp(), # Create a unique temp directory
205
- "outputFilename": "model",
206
- "emitReport": True,
207
- "emitGeometry": True,
208
- # "yUp": False # PyPRT default is Y-up. Cesium is Z-up.
209
  }
210
 
211
  # Generate
212
  logger.info(f"Generating with RPK: {rpk_path}")
213
  logger.info(f"Using attributes: {request.attributes}")
214
 
 
 
 
 
215
  try:
216
- model_generator = pyprt.ModelGenerator([initial_shape])
217
-
218
- # Additional validation for attributes
219
- clean_attributes = {}
220
- for k, v in request.attributes.items():
221
- if v is not None:
222
- # FIX: PyPRT is sensitive to Int vs Float.
223
- # If the attribute is defined as Float in RPK, passing an Int (e.g. 100) causes generation to fail or return no model.
224
- # We convert all standard Integers to Floats to be safe, as CGA attributes are predominantly Floats.
225
- # Note: boolean is a subclass of int, so we must exclude bools.
226
- if isinstance(v, int) and not isinstance(v, bool):
227
- clean_attributes[k] = float(v)
228
- else:
229
- clean_attributes[k] = v
230
-
231
- logger.info(f"Clean attributes: {clean_attributes}")
232
 
 
 
233
  models = model_generator.generate_model(
234
- [clean_attributes],
235
- rpk_path,
236
- "com.esri.prt.codecs.OBJEncoder",
237
- export_options
238
  )
 
 
239
  except Exception as e:
240
  logger.error(f"PyPRT Generation Error: {e}")
241
  import traceback
242
  traceback.print_exc()
243
  raise HTTPException(status_code=500, detail=f"PyPRT Generation Failed: {str(e)}")
244
 
245
- # Check for output OBJ files
246
- output_path = export_options["outputPath"] # Define output_path here
247
- generated_files = glob.glob(os.path.join(output_path, "*.obj"))
248
 
 
 
 
 
 
249
  if generated_files:
250
- obj_path = generated_files[0]
251
- glb_path = os.path.join(output_path, "output.glb")
252
 
253
- logger.info(f"Converting {obj_path} to {glb_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
- # Convert OBJ to GLB using Trimesh
256
- try:
257
- mesh = trimesh.load(obj_path, process=False)
258
- mesh.export(glb_path)
259
- except Exception as e:
260
- logger.error(f"Conversion failed: {e}")
261
- raise HTTPException(status_code=500, detail=f"GLB Conversion failed: {str(e)}")
262
-
263
- if os.path.exists(glb_path):
264
- # No headers needed for placement logic anymore.
265
- # The frontend reconstructs placement from its own state.
266
- return FileResponse(glb_path, media_type="model/gltf-binary", filename="output.glb")
267
- else:
268
- logger.error(f"GLB file not created at {glb_path}")
269
- raise HTTPException(status_code=500, detail="GLB generation failed: Conversion produced no output")
270
  else:
271
- logger.error(f"No OBJ file found in {output_path}")
272
- raise HTTPException(status_code=500, detail="OBJ generation failed: No file created")
273
-
274
 
275
  except Exception as e:
276
  logger.error(f"Generation error: {e}")
@@ -368,24 +420,23 @@ async def generate_i3s(request: GenerateRequest):
368
  'globalOffset': anchor_ecef
369
  }
370
 
371
- # Clean attributes (same as before)
372
- clean_attributes = {}
373
- for k, v in request.attributes.items():
374
- if v is not None:
375
- if isinstance(v, int) and not isinstance(v, bool):
376
- clean_attributes[k] = float(v)
377
- else:
378
- clean_attributes[k] = v
379
-
380
  logger.info(f"Generating I3S to {output_dir}")
381
-
 
 
 
 
 
382
  model_generator = pyprt.ModelGenerator([initial_shape])
383
- model_generator.generate_model(
384
  [clean_attributes],
385
  rpk_path,
386
  'com.esri.prt.codecs.I3SEncoder',
387
- enc_options
388
  )
 
389
 
390
  # Verify output
391
  # The encoder usually creates a subfolder based on sceneName or baseName, or just dumps in outputPath?
@@ -430,6 +481,7 @@ async def generate_i3s(request: GenerateRequest):
430
  return {
431
  "layerUrl": json_file_path,
432
  "layerId": layer_id,
 
433
  "message": "I3S Layer Generated",
434
  "debug_files": debug_files[:20] # Return first 20 files for debugging
435
  }
@@ -525,4 +577,90 @@ async def i3s_smart_middleware(request: Request, call_next):
525
 
526
  @app.get("/")
527
  async def root():
528
- return {"message": "CityPyPRT 3D Generation API"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import tempfile
5
  import shapely.geometry
6
  import pyprt
 
7
  import glob
8
  import shutil
9
  import pyproj
 
65
  return None
66
  return path
67
 
68
+ # ---------------------------------------------------------------------------
69
+ # Shared helpers
70
+ # ---------------------------------------------------------------------------
71
+
72
+ def _clean_attributes(raw: Dict[str, Any]) -> Dict[str, Any]:
73
+ """Sanitise attribute values for PyPRT (ints must be floats, None dropped)."""
74
+ cleaned = {}
75
+ for k, v in raw.items():
76
+ if v is None:
77
+ continue
78
+ # PyPRT only accepts float | bool | str for CGA attributes
79
+ if isinstance(v, int) and not isinstance(v, bool):
80
+ cleaned[k] = float(v)
81
+ else:
82
+ cleaned[k] = v
83
+ return cleaned
84
+
85
+
86
+ def _extract_reports(
87
+ initial_shape: "pyprt.InitialShape",
88
+ clean_attrs: Dict[str, Any],
89
+ rpk_path: str,
90
+ ) -> Dict[str, Any]:
91
+ """
92
+ Run a dedicated PyEncoder pass (emitGeometry=False, emitReport=True) to
93
+ collect the CGA report dictionary.
94
+
95
+ Background
96
+ ----------
97
+ ``GeneratedModel.get_report()`` is **only** populated when the
98
+ ``com.esri.pyprt.PyEncoder`` is used. File-based encoders such as
99
+ ``GLTFEncoder`` and ``I3SEncoder`` write geometry to disk and return an
100
+ *empty* list from ``generate_model()``, so iterating over that list
101
+ never reaches ``get_report()``.
102
+
103
+ Reference: https://esri.github.io/pyprt/apidoc/pyprt.pyprt.html
104
+ """
105
+ reports: Dict[str, Any] = {}
106
+ try:
107
+ mg = pyprt.ModelGenerator([initial_shape])
108
+ report_models = mg.generate_model(
109
+ [clean_attrs],
110
+ rpk_path,
111
+ "com.esri.pyprt.PyEncoder",
112
+ {"emitReport": True, "emitGeometry": False},
113
+ )
114
+ for m in report_models:
115
+ rep = m.get_report()
116
+ if rep:
117
+ reports.update(rep)
118
+ logger.info(f"CGA reports extracted: {list(reports.keys())}")
119
+ except Exception as exc:
120
+ logger.warning(f"Report extraction failed (non-fatal): {exc}")
121
+ return reports
122
+
123
  @app.get("/rpks")
124
  async def list_rpks():
125
  """List available RPK files."""
 
256
  # "emitReport": True is needed to get the attributes back? No, that's get_attributes.
257
  export_options = {
258
  "outputPath": tempfile.mkdtemp(), # Create a unique temp directory
259
+ "outputFilename": "model", # GLTFEncoder defaults to building model.glb if we pass proper flags
260
+ "emitReport": True,
261
+ "emitGeometry": True
 
262
  }
263
 
264
  # Generate
265
  logger.info(f"Generating with RPK: {rpk_path}")
266
  logger.info(f"Using attributes: {request.attributes}")
267
 
268
+ # Sanitise attributes
269
+ clean_attributes = _clean_attributes(request.attributes)
270
+ logger.info(f"Clean attributes: {clean_attributes}")
271
+
272
  try:
273
+ # Pass 1 – extract CGA reports via PyEncoder
274
+ # (GLTFEncoder returns an empty list, so get_report() never fires)
275
+ reports_dict = _extract_reports(initial_shape, clean_attributes, rpk_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
+ # Pass 2 – generate the actual GLB geometry
278
+ model_generator = pyprt.ModelGenerator([initial_shape])
279
  models = model_generator.generate_model(
280
+ [clean_attributes],
281
+ rpk_path,
282
+ "com.esri.prt.codecs.GLTFEncoder",
283
+ export_options,
284
  )
285
+ # (models is empty for file encoders — that is expected)
286
+
287
  except Exception as e:
288
  logger.error(f"PyPRT Generation Error: {e}")
289
  import traceback
290
  traceback.print_exc()
291
  raise HTTPException(status_code=500, detail=f"PyPRT Generation Failed: {str(e)}")
292
 
293
+ output_path = export_options["outputPath"]
 
 
294
 
295
+ # PyPRT GLTFEncoder usually spits out .glb by default, or .gltf
296
+ generated_files = glob.glob(os.path.join(output_path, "*.glb"))
297
+ if not generated_files:
298
+ generated_files = glob.glob(os.path.join(output_path, "*.gltf"))
299
+
300
  if generated_files:
301
+ glb_path = generated_files[0]
302
+ logger.info(f"Found GLTF/GLB at {glb_path}")
303
 
304
+ # Return JSONResponse where we provide the file download link and the reports
305
+ # But the requirement from frontend is typically:
306
+ # - if we want URL and reports together, we need a JSON response
307
+ # - we can statically serve the generated file if it's placed in a static dir.
308
+ # Right now, FileResponse is returned directly.
309
+
310
+ uuid_folder = str(uuid.uuid4())
311
+ serve_dir = os.path.join(LAYERS_DIR, uuid_folder)
312
+ os.makedirs(serve_dir, exist_ok=True)
313
+
314
+ final_glb_path = os.path.join(serve_dir, "model.glb")
315
+ shutil.copy(glb_path, final_glb_path)
316
+
317
+ return JSONResponse(content={
318
+ "url": f"/layers/{uuid_folder}/model.glb",
319
+ "reports": reports_dict,
320
+ "message": "GLB Generated Natively"
321
+ })
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  else:
324
+ logger.error(f"No GLB/GLTF file found in {output_path}")
325
+ raise HTTPException(status_code=500, detail="PyPRT Generation failed: No GLB file created")
 
326
 
327
  except Exception as e:
328
  logger.error(f"Generation error: {e}")
 
420
  'globalOffset': anchor_ecef
421
  }
422
 
423
+ # Sanitise attributes
424
+ clean_attributes = _clean_attributes(request.attributes)
 
 
 
 
 
 
 
425
  logger.info(f"Generating I3S to {output_dir}")
426
+
427
+ # Pass 1 – extract CGA reports via PyEncoder
428
+ # (I3SEncoder returns an empty list, so get_report() never fires)
429
+ reports_dict = _extract_reports(initial_shape, clean_attributes, rpk_path)
430
+
431
+ # Pass 2 – generate the actual I3S scene layer
432
  model_generator = pyprt.ModelGenerator([initial_shape])
433
+ models = model_generator.generate_model(
434
  [clean_attributes],
435
  rpk_path,
436
  'com.esri.prt.codecs.I3SEncoder',
437
+ enc_options,
438
  )
439
+ # (models is empty for file encoders — that is expected)
440
 
441
  # Verify output
442
  # The encoder usually creates a subfolder based on sceneName or baseName, or just dumps in outputPath?
 
481
  return {
482
  "layerUrl": json_file_path,
483
  "layerId": layer_id,
484
+ "reports": reports_dict,
485
  "message": "I3S Layer Generated",
486
  "debug_files": debug_files[:20] # Return first 20 files for debugging
487
  }
 
577
 
578
  @app.get("/")
579
  async def root():
580
+ return {"message": "CityPyPRT 3D Generation API"}
581
+
582
+
583
+ # ---------------------------------------------------------------------------
584
+ # Dedicated report endpoint
585
+ # ---------------------------------------------------------------------------
586
+
587
+ @app.post("/report")
588
+ async def get_model_report(request: GenerateRequest):
589
+ """
590
+ Return **only** the CGA report dict for a given geometry + RPK, without
591
+ writing any geometry files to disk.
592
+
593
+ This uses ``com.esri.pyprt.PyEncoder`` with ``emitReport=True`` and
594
+ ``emitGeometry=False`` — the only encoder that populates
595
+ ``GeneratedModel.get_report()``.
596
+
597
+ Request body (same as /generate)
598
+ ---------------------------------
599
+ .. code-block:: json
600
+
601
+ {
602
+ "rpk_name": "Building.rpk",
603
+ "geometry": { "type": "Polygon", "coordinates": [...] },
604
+ "attributes": { "buildingHeight": 30.0 }
605
+ }
606
+
607
+ Response
608
+ --------
609
+ .. code-block:: json
610
+
611
+ {
612
+ "report": { "Ground Floor Area": 250.0, "Building Volume": 3200.0 },
613
+ "rpk_name": "Building.rpk"
614
+ }
615
+ """
616
+ rpk_path = get_rpk_path(request.rpk_name)
617
+ if not rpk_path:
618
+ raise HTTPException(status_code=404, detail=f"RPK '{request.rpk_name}' not found")
619
+
620
+ try:
621
+ # --- Parse geometry (same logic as /generate) ---
622
+ geom_dict = request.geometry
623
+ if geom_dict.get("type") == "Feature":
624
+ geom_dict = geom_dict.get("geometry")
625
+
626
+ if geom_dict.get("type") != "Polygon":
627
+ raise HTTPException(status_code=400, detail="Only Polygon geometries are supported")
628
+
629
+ shape = shapely.geometry.shape(geom_dict)
630
+ if not shape.is_valid:
631
+ shape = shape.buffer(0)
632
+ shape = shapely.ops.orient(shape, sign=-1.0) # CW, consistent with /generate
633
+
634
+ centroid = shape.centroid
635
+ shape_centered = translate(shape, xoff=-centroid.x, yoff=-centroid.y)
636
+
637
+ coords = list(shape_centered.exterior.coords)
638
+ if coords[0] == coords[-1]:
639
+ coords = coords[:-1]
640
+
641
+ flattened_coords = []
642
+ for p in coords:
643
+ flattened_coords.extend([p[0], 0, p[1]])
644
+
645
+ indices = list(range(len(coords)))
646
+ face_counts = [len(coords)]
647
+ initial_shape = pyprt.InitialShape(flattened_coords, indices, face_counts)
648
+
649
+ # --- Sanitise attributes ---
650
+ clean_attributes = _clean_attributes(request.attributes)
651
+
652
+ # --- Run PyEncoder report pass ---
653
+ reports_dict = _extract_reports(initial_shape, clean_attributes, rpk_path)
654
+
655
+ return JSONResponse(content={
656
+ "report": reports_dict,
657
+ "rpk_name": request.rpk_name,
658
+ })
659
+
660
+ except HTTPException:
661
+ raise
662
+ except Exception as exc:
663
+ logger.error(f"Report endpoint error: {exc}")
664
+ import traceback
665
+ traceback.print_exc()
666
+ raise HTTPException(status_code=500, detail=str(exc))