satprint 0.2.1__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.
- satprint/__init__.py +3 -0
- satprint/__main__.py +3 -0
- satprint/app.py +635 -0
- satprint/buildings.py +795 -0
- satprint/cli.py +294 -0
- satprint/landmarks.py +91 -0
- satprint/mesh.py +753 -0
- satprint/osm.py +433 -0
- satprint/overture.py +167 -0
- satprint/presets.py +115 -0
- satprint/static/app.js +398 -0
- satprint/static/index.html +141 -0
- satprint/static/satprint-mark.svg +30 -0
- satprint/static/style.css +72 -0
- satprint/terrain.py +579 -0
- satprint/water.py +142 -0
- satprint-0.2.1.dist-info/METADATA +368 -0
- satprint-0.2.1.dist-info/RECORD +21 -0
- satprint-0.2.1.dist-info/WHEEL +4 -0
- satprint-0.2.1.dist-info/entry_points.txt +3 -0
- satprint-0.2.1.dist-info/licenses/LICENSE +21 -0
satprint/__init__.py
ADDED
satprint/__main__.py
ADDED
satprint/app.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""FastAPI backend: terrain lookup, model generation, STL and GLB download."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
|
|
16
|
+
from fastapi.responses import FileResponse, Response
|
|
17
|
+
from fastapi.staticfiles import StaticFiles
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .buildings import (
|
|
22
|
+
MAX_BUILDING_AREA_KM2,
|
|
23
|
+
BuildingMesh,
|
|
24
|
+
bbox_area_km2,
|
|
25
|
+
building_mesh,
|
|
26
|
+
)
|
|
27
|
+
from .mesh import (
|
|
28
|
+
Mesh,
|
|
29
|
+
frame_mesh,
|
|
30
|
+
heightmap_to_mesh,
|
|
31
|
+
merge_meshes,
|
|
32
|
+
write_3mf,
|
|
33
|
+
write_binary_stl,
|
|
34
|
+
write_glb,
|
|
35
|
+
)
|
|
36
|
+
from .osm import (
|
|
37
|
+
OSM_ATTRIBUTION,
|
|
38
|
+
OVERTURE_ATTRIBUTION,
|
|
39
|
+
Geocoder,
|
|
40
|
+
OverpassClient,
|
|
41
|
+
VectorTileClient,
|
|
42
|
+
fetch_buildings,
|
|
43
|
+
)
|
|
44
|
+
from .presets import presets as all_presets
|
|
45
|
+
from .terrain import (
|
|
46
|
+
IMAGERY_ATTRIBUTION,
|
|
47
|
+
BBox,
|
|
48
|
+
Heightmap,
|
|
49
|
+
ImageryFetcher,
|
|
50
|
+
PrintParams,
|
|
51
|
+
Progress,
|
|
52
|
+
TileFetcher,
|
|
53
|
+
fetch_imagery,
|
|
54
|
+
fetch_terrarium,
|
|
55
|
+
heightmap_png,
|
|
56
|
+
load_heightmap_file,
|
|
57
|
+
prepare_relief,
|
|
58
|
+
synthetic_heightmap,
|
|
59
|
+
)
|
|
60
|
+
from .water import multicolor_parts, water_from_vector_tiles, water_zoom
|
|
61
|
+
|
|
62
|
+
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
|
63
|
+
|
|
64
|
+
PRESETS = all_presets()
|
|
65
|
+
|
|
66
|
+
# ----------------------------------------------------------------------------
|
|
67
|
+
# In-memory stores with a small LRU cap
|
|
68
|
+
# ----------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _LRU:
|
|
72
|
+
def __init__(self, cap: int):
|
|
73
|
+
self.cap, self._d, self._lock = cap, OrderedDict(), threading.Lock()
|
|
74
|
+
|
|
75
|
+
def get(self, key):
|
|
76
|
+
with self._lock:
|
|
77
|
+
if key in self._d:
|
|
78
|
+
self._d.move_to_end(key)
|
|
79
|
+
return self._d[key]
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def put(self, key, value):
|
|
83
|
+
with self._lock:
|
|
84
|
+
self._d[key] = value
|
|
85
|
+
self._d.move_to_end(key)
|
|
86
|
+
while len(self._d) > self.cap:
|
|
87
|
+
self._d.popitem(last=False)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class StoredModel:
|
|
92
|
+
stl: bytes
|
|
93
|
+
png: bytes
|
|
94
|
+
info: dict
|
|
95
|
+
created: float
|
|
96
|
+
glb: bytes | None = None
|
|
97
|
+
threemf: bytes | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class Job:
|
|
102
|
+
"""A background model build and how far it has got."""
|
|
103
|
+
|
|
104
|
+
id: str
|
|
105
|
+
status: str = "running" # running | done | error
|
|
106
|
+
stage: str = "starting"
|
|
107
|
+
done: int = 0
|
|
108
|
+
total: int = 0 # 0 while a stage has no count
|
|
109
|
+
started: float = field(default_factory=time.time)
|
|
110
|
+
result: dict | None = None
|
|
111
|
+
error: str | None = None
|
|
112
|
+
status_code: int | None = None
|
|
113
|
+
|
|
114
|
+
def as_dict(self) -> dict:
|
|
115
|
+
return {
|
|
116
|
+
"job_id": self.id,
|
|
117
|
+
"status": self.status,
|
|
118
|
+
"stage": self.stage,
|
|
119
|
+
"done": self.done,
|
|
120
|
+
"total": self.total,
|
|
121
|
+
"elapsed_s": round(time.time() - self.started, 1),
|
|
122
|
+
"result": self.result,
|
|
123
|
+
"error": self.error,
|
|
124
|
+
"status_code": self.status_code,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
jobs = _LRU(64) # job_id -> Job
|
|
129
|
+
terrain_cache = _LRU(32) # key -> Heightmap (downloads are the slow part)
|
|
130
|
+
imagery_cache = _LRU(16) # bbox key -> (JPEG bytes, meta)
|
|
131
|
+
water_cache = _LRU(16) # bbox key -> list of water polygons
|
|
132
|
+
buildings_cache = _LRU(16) # bbox key -> list[Building]
|
|
133
|
+
upload_store = _LRU(16) # upload_id -> Heightmap
|
|
134
|
+
model_store = _LRU(32) # model_id -> StoredModel
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ----------------------------------------------------------------------------
|
|
138
|
+
# Schemas
|
|
139
|
+
# ----------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class BBoxIn(BaseModel):
|
|
143
|
+
south: float
|
|
144
|
+
west: float
|
|
145
|
+
north: float
|
|
146
|
+
east: float
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ModelRequest(BaseModel):
|
|
150
|
+
source: Literal["terrarium", "synthetic", "upload"] = "terrarium"
|
|
151
|
+
bbox: BBoxIn | None = None
|
|
152
|
+
upload_id: str | None = None
|
|
153
|
+
ground_width_m: float | None = Field(None, gt=0, description="override for uploads")
|
|
154
|
+
seed: int = 0
|
|
155
|
+
resolution: int = Field(256, ge=32, le=1024, description="grid columns")
|
|
156
|
+
width_mm: float = Field(100.0, ge=10, le=1000)
|
|
157
|
+
base_mm: float = Field(3.0, ge=0.5, le=100)
|
|
158
|
+
exaggeration: float = Field(1.5, ge=0.1, le=20)
|
|
159
|
+
relief_mm: float | None = Field(None, ge=0.2, le=500)
|
|
160
|
+
smoothing: float = Field(0.0, ge=0, le=20)
|
|
161
|
+
clamp_sea_level: bool = True
|
|
162
|
+
texture: bool = Field(
|
|
163
|
+
True, description="drape satellite imagery on a GLB (terrarium source only)"
|
|
164
|
+
)
|
|
165
|
+
buildings: bool = Field(
|
|
166
|
+
False, description="add OpenStreetMap buildings (terrarium source only)"
|
|
167
|
+
)
|
|
168
|
+
building_scale: float = Field(
|
|
169
|
+
1.0,
|
|
170
|
+
ge=0.25,
|
|
171
|
+
le=20,
|
|
172
|
+
description="building height multiplier; 1 = true proportion",
|
|
173
|
+
)
|
|
174
|
+
building_source: Literal["auto", "openfreemap", "overpass", "overture"] = Field(
|
|
175
|
+
"auto",
|
|
176
|
+
description="OpenFreeMap vector tiles (fast, rebuilt weekly), Overpass "
|
|
177
|
+
"(latest OSM edits, slower), Overture Maps (OSM plus Microsoft and "
|
|
178
|
+
"Google footprints; needs the overture extra), or auto: OpenFreeMap, then Overpass",
|
|
179
|
+
)
|
|
180
|
+
frame_mm: float = Field(
|
|
181
|
+
0.0, ge=0, le=30, description="border frame width around the model; 0 = none"
|
|
182
|
+
)
|
|
183
|
+
frame_height_mm: float | None = Field(
|
|
184
|
+
None, gt=0, le=100, description="frame height; default base thickness + 1 mm"
|
|
185
|
+
)
|
|
186
|
+
multicolor: bool = Field(
|
|
187
|
+
False,
|
|
188
|
+
description="also write a 3MF with land, water and buildings as separate "
|
|
189
|
+
"parts, for multi-material printers (terrarium source only)",
|
|
190
|
+
)
|
|
191
|
+
name: str = Field("terrain", max_length=60)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ModelResponse(BaseModel):
|
|
195
|
+
model_id: str
|
|
196
|
+
stl_url: str
|
|
197
|
+
png_url: str
|
|
198
|
+
glb_url: str | None = None
|
|
199
|
+
threemf_url: str | None = None
|
|
200
|
+
info: dict
|
|
201
|
+
preview_png: str # data URL
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ----------------------------------------------------------------------------
|
|
205
|
+
# App
|
|
206
|
+
# ----------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def create_app(
|
|
210
|
+
tile_fetcher: TileFetcher | None = None,
|
|
211
|
+
imagery_fetcher: ImageryFetcher | None = None,
|
|
212
|
+
overpass: OverpassClient | None = None,
|
|
213
|
+
geocoder: Geocoder | None = None,
|
|
214
|
+
vector_tiles: VectorTileClient | None = None,
|
|
215
|
+
) -> FastAPI:
|
|
216
|
+
app = FastAPI(
|
|
217
|
+
title="satprint",
|
|
218
|
+
version=__version__,
|
|
219
|
+
description="Turn satellite elevation data into 3D-printable terrain models.",
|
|
220
|
+
)
|
|
221
|
+
fetcher = tile_fetcher or TileFetcher()
|
|
222
|
+
imagery = imagery_fetcher or ImageryFetcher()
|
|
223
|
+
osm = overpass or OverpassClient()
|
|
224
|
+
vtiles = vector_tiles or VectorTileClient()
|
|
225
|
+
|
|
226
|
+
def _water_for(bbox: BBox, progress: Progress | None = None) -> list:
|
|
227
|
+
key = _bbox_key(bbox)
|
|
228
|
+
hit = water_cache.get(key)
|
|
229
|
+
if hit is None:
|
|
230
|
+
tiles = vtiles.tiles(
|
|
231
|
+
bbox, progress=progress, zoom=water_zoom(bbox), stage="water"
|
|
232
|
+
)
|
|
233
|
+
hit = water_from_vector_tiles(tiles)
|
|
234
|
+
water_cache.put(key, hit)
|
|
235
|
+
return hit
|
|
236
|
+
|
|
237
|
+
geo = geocoder or Geocoder()
|
|
238
|
+
|
|
239
|
+
def _bbox_key(bbox: BBox) -> tuple:
|
|
240
|
+
return tuple(
|
|
241
|
+
round(v, 5) for v in (bbox.south, bbox.west, bbox.north, bbox.east)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _buildings_for(
|
|
245
|
+
bbox: BBox, source: str, progress: Progress | None = None
|
|
246
|
+
) -> tuple[list, str | None, str]:
|
|
247
|
+
"""Buildings in ``bbox``, a roof-shape warning, and the source used."""
|
|
248
|
+
order = {"auto": ["openfreemap", "overpass"]}.get(source, [source])
|
|
249
|
+
errors = []
|
|
250
|
+
for name in order:
|
|
251
|
+
key = (name, *_bbox_key(bbox))
|
|
252
|
+
hit = buildings_cache.get(key)
|
|
253
|
+
if hit is not None:
|
|
254
|
+
return *hit, name
|
|
255
|
+
try:
|
|
256
|
+
hit = fetch_buildings(name, bbox, osm, vtiles, progress)
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
errors.append(f"{name}: {exc}")
|
|
259
|
+
continue
|
|
260
|
+
buildings_cache.put(key, hit)
|
|
261
|
+
return *hit, name
|
|
262
|
+
raise RuntimeError("; ".join(errors))
|
|
263
|
+
|
|
264
|
+
def _texture_for(
|
|
265
|
+
bbox: BBox, progress: Progress | None = None
|
|
266
|
+
) -> tuple[bytes, dict]:
|
|
267
|
+
key = _bbox_key(bbox)
|
|
268
|
+
hit = imagery_cache.get(key)
|
|
269
|
+
if hit is None:
|
|
270
|
+
img, meta = fetch_imagery(bbox, fetcher=imagery, progress=progress)
|
|
271
|
+
buf = io.BytesIO()
|
|
272
|
+
img.save(buf, format="JPEG", quality=90)
|
|
273
|
+
hit = (buf.getvalue(), meta)
|
|
274
|
+
imagery_cache.put(key, hit)
|
|
275
|
+
return hit
|
|
276
|
+
|
|
277
|
+
def _terrain_for(req: ModelRequest, progress: Progress | None = None) -> Heightmap:
|
|
278
|
+
if req.source == "synthetic":
|
|
279
|
+
key = ("synthetic", req.seed, req.resolution)
|
|
280
|
+
hm = terrain_cache.get(key)
|
|
281
|
+
if hm is None:
|
|
282
|
+
hm = synthetic_heightmap(
|
|
283
|
+
rows=int(req.resolution * 0.75), cols=req.resolution, seed=req.seed
|
|
284
|
+
)
|
|
285
|
+
terrain_cache.put(key, hm)
|
|
286
|
+
return hm
|
|
287
|
+
if req.source == "upload":
|
|
288
|
+
if not req.upload_id:
|
|
289
|
+
raise HTTPException(400, "upload_id is required for source=upload")
|
|
290
|
+
hm = upload_store.get(req.upload_id)
|
|
291
|
+
if hm is None:
|
|
292
|
+
raise HTTPException(
|
|
293
|
+
404, "upload not found (server restarted?) — upload the file again"
|
|
294
|
+
)
|
|
295
|
+
if req.ground_width_m:
|
|
296
|
+
hm = Heightmap(
|
|
297
|
+
hm.data,
|
|
298
|
+
req.ground_width_m,
|
|
299
|
+
req.ground_width_m * hm.data.shape[0] / hm.data.shape[1],
|
|
300
|
+
hm.source,
|
|
301
|
+
hm.bbox,
|
|
302
|
+
hm.meta,
|
|
303
|
+
)
|
|
304
|
+
return hm
|
|
305
|
+
if req.bbox is None:
|
|
306
|
+
raise HTTPException(400, "bbox is required for source=terrarium")
|
|
307
|
+
try:
|
|
308
|
+
bbox = BBox(**req.bbox.model_dump())
|
|
309
|
+
except ValueError as exc:
|
|
310
|
+
raise HTTPException(400, str(exc)) from exc
|
|
311
|
+
key = (
|
|
312
|
+
"terrarium",
|
|
313
|
+
round(bbox.south, 5),
|
|
314
|
+
round(bbox.west, 5),
|
|
315
|
+
round(bbox.north, 5),
|
|
316
|
+
round(bbox.east, 5),
|
|
317
|
+
req.resolution,
|
|
318
|
+
)
|
|
319
|
+
hm = terrain_cache.get(key)
|
|
320
|
+
if hm is None:
|
|
321
|
+
try:
|
|
322
|
+
hm = fetch_terrarium(
|
|
323
|
+
bbox, target_cols=req.resolution, fetcher=fetcher, progress=progress
|
|
324
|
+
)
|
|
325
|
+
except ValueError as exc:
|
|
326
|
+
raise HTTPException(400, str(exc)) from exc
|
|
327
|
+
except LookupError as exc:
|
|
328
|
+
raise HTTPException(404, str(exc)) from exc
|
|
329
|
+
except Exception as exc: # network / decode failures
|
|
330
|
+
raise HTTPException(502, f"elevation download failed: {exc}") from exc
|
|
331
|
+
terrain_cache.put(key, hm)
|
|
332
|
+
return hm
|
|
333
|
+
|
|
334
|
+
@app.get("/api/presets")
|
|
335
|
+
def presets():
|
|
336
|
+
return PRESETS
|
|
337
|
+
|
|
338
|
+
@app.get("/api/search")
|
|
339
|
+
def search(q: str = Query(..., min_length=2, max_length=200)):
|
|
340
|
+
try:
|
|
341
|
+
return geo.search(q)
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
raise HTTPException(502, f"place search failed: {exc}") from exc
|
|
344
|
+
|
|
345
|
+
@app.get("/api/health")
|
|
346
|
+
def health():
|
|
347
|
+
return {"ok": True, "version": __version__}
|
|
348
|
+
|
|
349
|
+
@app.post("/api/upload")
|
|
350
|
+
async def upload(file: UploadFile = File(...)):
|
|
351
|
+
data = await file.read()
|
|
352
|
+
if len(data) > 60 * 1024 * 1024:
|
|
353
|
+
raise HTTPException(413, "file too large (60 MB limit)")
|
|
354
|
+
try:
|
|
355
|
+
hm = load_heightmap_file(data, file.filename or "")
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
raise HTTPException(400, f"could not read heightmap: {exc}") from exc
|
|
358
|
+
upload_id = uuid.uuid4().hex[:12]
|
|
359
|
+
upload_store.put(upload_id, hm)
|
|
360
|
+
return {
|
|
361
|
+
"upload_id": upload_id,
|
|
362
|
+
"rows": hm.shape[0],
|
|
363
|
+
"cols": hm.shape[1],
|
|
364
|
+
"min_elev_m": hm.min,
|
|
365
|
+
"max_elev_m": hm.max,
|
|
366
|
+
"ground_width_m": hm.ground_width_m,
|
|
367
|
+
"ground_height_m": hm.ground_height_m,
|
|
368
|
+
"meta": hm.meta,
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
def _build(req: ModelRequest, progress: Progress | None = None) -> ModelResponse:
|
|
372
|
+
"""Build a model; ``progress`` hears each stage as it advances."""
|
|
373
|
+
hm = _terrain_for(req, progress)
|
|
374
|
+
try:
|
|
375
|
+
params = PrintParams(
|
|
376
|
+
width_mm=req.width_mm,
|
|
377
|
+
base_mm=req.base_mm,
|
|
378
|
+
exaggeration=req.exaggeration,
|
|
379
|
+
relief_mm=req.relief_mm,
|
|
380
|
+
smoothing=req.smoothing,
|
|
381
|
+
clamp_sea_level=req.clamp_sea_level,
|
|
382
|
+
)
|
|
383
|
+
except ValueError as exc:
|
|
384
|
+
raise HTTPException(400, str(exc)) from exc
|
|
385
|
+
t0 = time.perf_counter()
|
|
386
|
+
if progress:
|
|
387
|
+
progress("terrain mesh", 0, 0)
|
|
388
|
+
relief, info = prepare_relief(hm, params)
|
|
389
|
+
mesh: Mesh = heightmap_to_mesh(
|
|
390
|
+
relief, info["width_mm"], info["depth_mm"], params.base_mm
|
|
391
|
+
)
|
|
392
|
+
frame: Mesh | None = None
|
|
393
|
+
body = mesh # terrain plus frame: what the STL and GLB carry
|
|
394
|
+
if req.frame_mm > 0:
|
|
395
|
+
frame_h = req.frame_height_mm or params.base_mm + 1.0
|
|
396
|
+
frame = frame_mesh(
|
|
397
|
+
info["width_mm"], info["depth_mm"], req.frame_mm, frame_h
|
|
398
|
+
)
|
|
399
|
+
body = merge_meshes(mesh, frame)
|
|
400
|
+
info.update(
|
|
401
|
+
{
|
|
402
|
+
"frame_mm": req.frame_mm,
|
|
403
|
+
"frame_height_mm": frame_h,
|
|
404
|
+
"outer_width_mm": info["width_mm"] + 2 * req.frame_mm,
|
|
405
|
+
"outer_depth_mm": info["depth_mm"] + 2 * req.frame_mm,
|
|
406
|
+
"height_mm": max(info["height_mm"], frame_h),
|
|
407
|
+
}
|
|
408
|
+
)
|
|
409
|
+
rows, cols = relief.shape
|
|
410
|
+
bmesh: BuildingMesh | None = None
|
|
411
|
+
building_info: dict = {}
|
|
412
|
+
if req.buildings and hm.bbox is not None:
|
|
413
|
+
area = bbox_area_km2(hm.bbox)
|
|
414
|
+
# Like the texture, a building failure should not cost the STL.
|
|
415
|
+
if area > MAX_BUILDING_AREA_KM2:
|
|
416
|
+
building_info["building_error"] = (
|
|
417
|
+
f"area is {area:.0f} km2; buildings are limited to "
|
|
418
|
+
f"{MAX_BUILDING_AREA_KM2:.0f} km2"
|
|
419
|
+
)
|
|
420
|
+
else:
|
|
421
|
+
try:
|
|
422
|
+
found, warning, used = _buildings_for(
|
|
423
|
+
hm.bbox, req.building_source, progress
|
|
424
|
+
)
|
|
425
|
+
except Exception as exc:
|
|
426
|
+
building_info["building_error"] = f"building download failed: {exc}"
|
|
427
|
+
else:
|
|
428
|
+
bmesh = building_mesh(
|
|
429
|
+
found,
|
|
430
|
+
hm.bbox,
|
|
431
|
+
relief,
|
|
432
|
+
info["width_mm"],
|
|
433
|
+
info["depth_mm"],
|
|
434
|
+
params.base_mm,
|
|
435
|
+
info["mm_per_m_plan"],
|
|
436
|
+
scale=req.building_scale,
|
|
437
|
+
progress=progress,
|
|
438
|
+
)
|
|
439
|
+
building_info = {
|
|
440
|
+
"buildings": bmesh.count,
|
|
441
|
+
"building_source": used,
|
|
442
|
+
"building_attribution": (
|
|
443
|
+
OVERTURE_ATTRIBUTION
|
|
444
|
+
if used == "overture"
|
|
445
|
+
else OSM_ATTRIBUTION
|
|
446
|
+
),
|
|
447
|
+
}
|
|
448
|
+
if warning:
|
|
449
|
+
building_info["building_warning"] = warning
|
|
450
|
+
if bmesh.count:
|
|
451
|
+
top = float(bmesh.vertices[:, 2].max())
|
|
452
|
+
info["height_mm"] = max(info["height_mm"], top)
|
|
453
|
+
if progress:
|
|
454
|
+
progress("writing files", 0, 0)
|
|
455
|
+
solid = merge_meshes(body, bmesh.as_mesh()) if bmesh and bmesh.count else body
|
|
456
|
+
stl = write_binary_stl(solid, name=req.name)
|
|
457
|
+
png = heightmap_png(hm, params.clamp_sea_level)
|
|
458
|
+
threemf = None
|
|
459
|
+
multicolor_info: dict = {}
|
|
460
|
+
if req.multicolor and hm.bbox is not None:
|
|
461
|
+
polygons: list = []
|
|
462
|
+
try:
|
|
463
|
+
polygons = _water_for(hm.bbox, progress)
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
# Coasts still color from the flattened sea; say why rivers
|
|
466
|
+
# and lakes are missing.
|
|
467
|
+
multicolor_info["water_error"] = f"water download failed: {exc}"
|
|
468
|
+
parts, mask = multicolor_parts(
|
|
469
|
+
polygons,
|
|
470
|
+
hm.bbox,
|
|
471
|
+
relief,
|
|
472
|
+
info["width_mm"],
|
|
473
|
+
info["depth_mm"],
|
|
474
|
+
params.base_mm,
|
|
475
|
+
sea_level_flat=params.clamp_sea_level and info["min_elev_m"] <= 0,
|
|
476
|
+
buildings=bmesh,
|
|
477
|
+
frame=frame,
|
|
478
|
+
)
|
|
479
|
+
sources = [OSM_ATTRIBUTION] if polygons else []
|
|
480
|
+
if bmesh and bmesh.count:
|
|
481
|
+
sources.append(building_info["building_attribution"])
|
|
482
|
+
credits = "; ".join(dict.fromkeys(sources)) or None
|
|
483
|
+
threemf = write_3mf(parts, name=req.name, attribution=credits)
|
|
484
|
+
multicolor_info.update(
|
|
485
|
+
{
|
|
486
|
+
"multicolor_parts": [p[0] for p in parts if p[1].faces.shape[0]],
|
|
487
|
+
"water_fraction": round(float(mask.mean()), 4),
|
|
488
|
+
"threemf_bytes": len(threemf),
|
|
489
|
+
}
|
|
490
|
+
)
|
|
491
|
+
glb = None
|
|
492
|
+
texture_info: dict = {"textured": False}
|
|
493
|
+
if req.texture and hm.bbox is not None:
|
|
494
|
+
# A missing texture should not cost the user their STL.
|
|
495
|
+
try:
|
|
496
|
+
jpeg, meta = _texture_for(hm.bbox, progress)
|
|
497
|
+
except Exception as exc:
|
|
498
|
+
texture_info["texture_error"] = f"imagery download failed: {exc}"
|
|
499
|
+
else:
|
|
500
|
+
credits = IMAGERY_ATTRIBUTION
|
|
501
|
+
if bmesh and bmesh.count:
|
|
502
|
+
credits += "; " + building_info["building_attribution"]
|
|
503
|
+
glb = write_glb(
|
|
504
|
+
body,
|
|
505
|
+
rows,
|
|
506
|
+
cols,
|
|
507
|
+
jpeg,
|
|
508
|
+
name=req.name,
|
|
509
|
+
copyright=credits,
|
|
510
|
+
buildings=bmesh,
|
|
511
|
+
)
|
|
512
|
+
texture_info = {
|
|
513
|
+
"textured": True,
|
|
514
|
+
"texture_meta": meta,
|
|
515
|
+
"glb_bytes": len(glb),
|
|
516
|
+
"texture_attribution": IMAGERY_ATTRIBUTION,
|
|
517
|
+
}
|
|
518
|
+
vol_cm3 = solid.volume_mm3() / 1000.0
|
|
519
|
+
info.update(
|
|
520
|
+
{
|
|
521
|
+
"triangles": solid.triangle_count,
|
|
522
|
+
"volume_cm3": vol_cm3,
|
|
523
|
+
"est_weight_g_pla_solid": vol_cm3 * 1.24,
|
|
524
|
+
"est_weight_g_pla_20pct": vol_cm3
|
|
525
|
+
* 1.24
|
|
526
|
+
* 0.35, # shell + 20 % infill rule of thumb
|
|
527
|
+
"stl_bytes": len(stl),
|
|
528
|
+
"source": hm.source,
|
|
529
|
+
"source_meta": hm.meta,
|
|
530
|
+
"ground_width_m": hm.ground_width_m,
|
|
531
|
+
"ground_height_m": hm.ground_height_m,
|
|
532
|
+
"generate_seconds": round(time.perf_counter() - t0, 3),
|
|
533
|
+
**texture_info,
|
|
534
|
+
**building_info,
|
|
535
|
+
**multicolor_info,
|
|
536
|
+
}
|
|
537
|
+
)
|
|
538
|
+
model_id = uuid.uuid4().hex[:12]
|
|
539
|
+
model_store.put(
|
|
540
|
+
model_id,
|
|
541
|
+
StoredModel(
|
|
542
|
+
stl=stl,
|
|
543
|
+
png=png,
|
|
544
|
+
info=info,
|
|
545
|
+
created=time.time(),
|
|
546
|
+
glb=glb,
|
|
547
|
+
threemf=threemf,
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
safe = (
|
|
551
|
+
"".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in req.name)
|
|
552
|
+
or "terrain"
|
|
553
|
+
)
|
|
554
|
+
return ModelResponse(
|
|
555
|
+
model_id=model_id,
|
|
556
|
+
stl_url=f"/api/model/{model_id}/{safe}.stl",
|
|
557
|
+
png_url=f"/api/model/{model_id}/heightmap.png",
|
|
558
|
+
glb_url=f"/api/model/{model_id}/{safe}.glb" if glb else None,
|
|
559
|
+
threemf_url=f"/api/model/{model_id}/{safe}.3mf" if threemf else None,
|
|
560
|
+
info=info,
|
|
561
|
+
preview_png="data:image/png;base64," + base64.b64encode(png).decode(),
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
@app.post("/api/model", response_model=ModelResponse)
|
|
565
|
+
def make_model(req: ModelRequest):
|
|
566
|
+
return _build(req)
|
|
567
|
+
|
|
568
|
+
@app.post("/api/jobs")
|
|
569
|
+
def start_job(req: ModelRequest):
|
|
570
|
+
"""Build a model in the background; poll ``GET /api/jobs/{id}``."""
|
|
571
|
+
job = Job(id=uuid.uuid4().hex[:12])
|
|
572
|
+
jobs.put(job.id, job)
|
|
573
|
+
|
|
574
|
+
def report(stage: str, done: int, total: int) -> None:
|
|
575
|
+
job.stage, job.done, job.total = stage, done, total
|
|
576
|
+
|
|
577
|
+
def run() -> None:
|
|
578
|
+
try:
|
|
579
|
+
job.result = _build(req, report).model_dump()
|
|
580
|
+
job.status = "done"
|
|
581
|
+
except HTTPException as exc:
|
|
582
|
+
job.error, job.status_code = str(exc.detail), exc.status_code
|
|
583
|
+
job.status = "error"
|
|
584
|
+
except Exception as exc:
|
|
585
|
+
job.error, job.status_code = f"model build failed: {exc}", 500
|
|
586
|
+
job.status = "error"
|
|
587
|
+
|
|
588
|
+
threading.Thread(target=run, name=f"job-{job.id}", daemon=True).start()
|
|
589
|
+
return {"job_id": job.id}
|
|
590
|
+
|
|
591
|
+
@app.get("/api/jobs/{job_id}")
|
|
592
|
+
def job_status(job_id: str):
|
|
593
|
+
job = jobs.get(job_id)
|
|
594
|
+
if job is None:
|
|
595
|
+
raise HTTPException(404, "job not found (server restarted?)")
|
|
596
|
+
return job.as_dict()
|
|
597
|
+
|
|
598
|
+
@app.get("/api/model/{model_id}/{filename}")
|
|
599
|
+
def download(model_id: str, filename: str):
|
|
600
|
+
m = model_store.get(model_id)
|
|
601
|
+
if m is None:
|
|
602
|
+
raise HTTPException(404, "model not found — generate it again")
|
|
603
|
+
if filename.endswith(".png"):
|
|
604
|
+
return Response(m.png, media_type="image/png")
|
|
605
|
+
if filename.endswith(".stl"):
|
|
606
|
+
return Response(
|
|
607
|
+
m.stl,
|
|
608
|
+
media_type="model/stl",
|
|
609
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
610
|
+
)
|
|
611
|
+
if filename.endswith(".glb") and m.glb is not None:
|
|
612
|
+
return Response(
|
|
613
|
+
m.glb,
|
|
614
|
+
media_type="model/gltf-binary",
|
|
615
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
616
|
+
)
|
|
617
|
+
if filename.endswith(".3mf") and m.threemf is not None:
|
|
618
|
+
return Response(
|
|
619
|
+
m.threemf,
|
|
620
|
+
media_type="model/3mf",
|
|
621
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
622
|
+
)
|
|
623
|
+
if filename == "info.json":
|
|
624
|
+
return m.info
|
|
625
|
+
raise HTTPException(404, "unknown file")
|
|
626
|
+
|
|
627
|
+
@app.get("/", include_in_schema=False)
|
|
628
|
+
def index():
|
|
629
|
+
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
|
|
630
|
+
|
|
631
|
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
632
|
+
return app
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
app = create_app()
|