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 ADDED
@@ -0,0 +1,3 @@
1
+ """satprint -- turn satellite elevation data into 3D-printable terrain models."""
2
+
3
+ __version__ = "0.2.1"
satprint/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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()