Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
Each release is listed with its breaking changes first. Releases before
v0.8.0 are described in their tag messages.

## Unreleased

Features:
- GET /api/v1/surfaces returns the roof and wall surfaces of every
building in a WGS84 bbox, for rendering: per building its object_id,
its OSM ways and each surface's id (the same as /geometry's), type
and GeoJSON Polygon in EPSG:4326 with height, to 7 decimals. Pages
hold whole buildings up to about 4 MB, in object_id order; pass
next_cursor back until it is null. /geometry still serves surfaces
for one building at a time.

## v0.12.0 (2026-10-23)

Features:
Expand Down
99 changes: 99 additions & 0 deletions docs/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,53 @@ paths:
$ref: "#/components/schemas/Error"


/api/v1/surfaces:
get:
summary: Roof and wall surfaces in a bbox, paged
operationId: getSurfaces
description: |
Every building whose footprint intersects the bbox (all four bbox
parameters are required), with its roof and wall surfaces as 3D polygons
in WGS84, for rendering. The surfaces are those of
`/geometry?include=surfaces` without ground surfaces. Buildings come in
object_id order, one page at a time: request the first page without
`cursor`, then pass each response's `next_cursor` unchanged until it is
null. A page holds whole buildings and stays near 4 MB.

```
curl "http://localhost:5050/api/v1/surfaces?country=germany&xmin=12.95&ymin=48.83&xmax=12.97&ymax=48.84"
```
parameters:
- $ref: "#/components/parameters/Country"
- $ref: "#/components/parameters/Xmin"
- $ref: "#/components/parameters/Ymin"
- $ref: "#/components/parameters/Xmax"
- $ref: "#/components/parameters/Ymax"
- name: cursor
in: query
schema:
type: string
description: The previous page's `next_cursor`. Omit it for the first page.
responses:
"200":
description: One page of buildings with their surfaces, and the credit of every dataset they come from. An unconfigured country gives empty lists.
content:
application/json:
schema:
$ref: "#/components/schemas/SurfacesResponse"
"400":
description: Missing country, a missing or non-numeric bbox value, or a cursor this API did not return. Not retryable unchanged.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
description: Database query failed, or a returned dataset has no stored credit. Not retryable until the database is fixed (run `c2t -sync-attribution` for a missing credit).
content:
application/json:
schema:
$ref: "#/components/schemas/Error"

components:
parameters:
Country:
Expand Down Expand Up @@ -423,6 +470,58 @@ components:
items:
$ref: "#/components/schemas/DatasetCredit"

SurfacesResponse:
type: object
required: [buildings, attributions, next_cursor]
properties:
buildings:
type: array
items:
$ref: "#/components/schemas/BuildingSurfaces"
attributions:
type: array
description: One entry per dataset in `buildings`
items:
$ref: "#/components/schemas/DatasetCredit"
next_cursor:
type: string
nullable: true
description: Pass as `cursor` for the next page; null on the last page. Opaque.
BuildingSurfaces:
type: object
required: [object_id, osm_ids, surfaces]
properties:
object_id:
type: string
example: DEBY_LOD2_7672144
osm_ids:
type: array
items: {type: string}
description: OSM ways linked to the building, best first; `[]` when none.
example: ["223728275"]
surfaces:
type: array
items:
$ref: "#/components/schemas/RenderSurface"
RenderSurface:
type: object
required: [id, type, geometry]
properties:
id:
type: string
description: Surface id, the same as `SurfaceGeometry.id` from `/geometry?include=surfaces`.
type:
type: string
enum: [RoofSurface, WallSurface]
description: CityGML surface class. Walls shared with an attached neighbour are included.
geometry:
type: object
description: |
GeoJSON Polygon in EPSG:4326, coordinates [longitude, latitude,
height in metres] to 7 decimals, no `crs` member. Coordinates are
absolute, with no origin subtracted. A renderer working in 32-bit
floats subtracts its own origin first: a float32 latitude near 49
degrees resolves only to about 0.4 m.
DatasetCredit:
type: object
description: A source dataset's attribution. Show `credit` with any output derived from the dataset.
Expand Down
73 changes: 73 additions & 0 deletions internal/api/handler/surfaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package handler

import (
"errors"
"net/http"

"github.com/thd-spatial-ai/city2tabula/internal/db"
"github.com/thd-spatial-ai/city2tabula/internal/onrequest"
)

// surfacePageVertices bounds one page by the vertices of its surfaces. Encoded
// at 7 decimals a vertex measures about 52 bytes with its share of the surface
// wrapper (Bavarian and Dutch data alike), so a page is about 4 MB, which stays
// under typical client response caps (10 MiB, for example).
const surfacePageVertices = 75_000

// surfacesResponse is the body of GET /api/v1/surfaces. NextCursor is null on
// the last page.
type surfacesResponse struct {
Buildings []onrequest.BuildingSurfaces `json:"buildings"`
Attributions []onrequest.DatasetCredit `json:"attributions"`
NextCursor *string `json:"next_cursor"`
}

// Surfaces handles GET /api/v1/surfaces?country=..&xmin=..&ymin=..&xmax=..&ymax=..[&cursor=..]:
// the roof and wall surfaces of every building in a WGS84 bbox, for rendering,
// one size-bounded page at a time.
func (h *Handler) Surfaces(w http.ResponseWriter, r *http.Request) {
country := r.URL.Query().Get("country")
if country == "" {
writeError(w, http.StatusBadRequest, "country query param is required")
return
}
bbox, err := parseBboxParams(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
after, err := onrequest.DecodeSurfaceCursor(r.URL.Query().Get("cursor"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}

cfg, pool, err := h.srv.PoolFor(country)
if errors.Is(err, db.ErrCountryNotConfigured) {
writeJSON(w, http.StatusOK, surfacesResponse{Buildings: []onrequest.BuildingSurfaces{}, Attributions: []onrequest.DatasetCredit{}})
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}

page, err := onrequest.SurfacesInBBox(r.Context(), pool, cfg, bbox, after, surfacePageVertices)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
credits, err := onrequest.CreditsFor(r.Context(), pool, cfg, onrequest.SurfaceDatasetIDs(page.Buildings))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp := surfacesResponse{Buildings: page.Buildings, Attributions: credits}
if resp.Buildings == nil {
resp.Buildings = []onrequest.BuildingSurfaces{}
}
if page.NextCursor != "" {
resp.NextCursor = &page.NextCursor
}
writeJSON(w, http.StatusOK, resp)
}
30 changes: 30 additions & 0 deletions internal/api/handler/surfaces_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package handler

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

// TestSurfaces_RejectsBadRequests pins the 400s, all decided before any
// database lookup.
func TestSurfaces_RejectsBadRequests(t *testing.T) {
h := New(nil)
const bbox = "&xmin=12.9&ymin=48.8&xmax=13.0&ymax=48.9"
tests := []struct{ name, query, wantErr string }{
{"no country", "?" + bbox[1:], "country"},
{"no bbox", "?country=germany", "xmin"},
{"bbox value not a number", "?country=germany&xmin=a&ymin=48.8&xmax=13&ymax=48.9", "xmin"},
{"cursor not one the API returned", "?country=germany" + bbox + "&cursor=%25%25", "cursor"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
h.Surfaces(rec, httptest.NewRequest(http.MethodGet, "/api/v1/surfaces"+tc.query, nil))
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), tc.wantErr) {
t.Errorf("status %d, body %s; want 400 mentioning %q", rec.Code, rec.Body.String(), tc.wantErr)
}
})
}
}
4 changes: 3 additions & 1 deletion internal/api/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import (
// GET /api/v1/runs/{id} (run status),
// GET /api/v1/coverage (pre-trigger check),
// GET /api/v1/buildings (thematic 3D attributes, no geometry),
// GET /api/v1/geometry (footprint geometry, fetched separately).
// GET /api/v1/geometry (footprint geometry, fetched separately),
// GET /api/v1/surfaces (roof and wall surfaces in a bbox, paged, for rendering).
// Every request is logged and carries an X-Request-ID (handler.LogRequests).
func New(h *handler.Handler) http.Handler {
mux := http.NewServeMux()
Expand All @@ -23,5 +24,6 @@ func New(h *handler.Handler) http.Handler {
mux.HandleFunc("GET /api/v1/coverage", h.Coverage)
mux.HandleFunc("GET /api/v1/buildings", h.Buildings)
mux.HandleFunc("GET /api/v1/geometry", h.Geometry)
mux.HandleFunc("GET /api/v1/surfaces", h.Surfaces)
return handler.LogRequests(mux)
}
Loading
Loading