diff --git a/CHANGELOG.md b/CHANGELOG.md index 145e565..c03234e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index b0e2c0e..22e868b 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -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: @@ -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. diff --git a/internal/api/handler/surfaces.go b/internal/api/handler/surfaces.go new file mode 100644 index 0000000..1e55f01 --- /dev/null +++ b/internal/api/handler/surfaces.go @@ -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) +} diff --git a/internal/api/handler/surfaces_test.go b/internal/api/handler/surfaces_test.go new file mode 100644 index 0000000..c155d81 --- /dev/null +++ b/internal/api/handler/surfaces_test.go @@ -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) + } + }) + } +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 2103fa9..cfa1e7d 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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() @@ -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) } diff --git a/internal/onrequest/surfaces.go b/internal/onrequest/surfaces.go new file mode 100644 index 0000000..8e2bc5e --- /dev/null +++ b/internal/onrequest/surfaces.go @@ -0,0 +1,201 @@ +package onrequest + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/thd-spatial-ai/city2tabula/internal/config" +) + +// renderedSurfaceTypes are the surfaces a 3D view draws; ground surfaces lie +// under the building and are left out. +var renderedSurfaceTypes = []string{"RoofSurface", "WallSurface"} + +// BuildingSurfaces is one building's roof and wall surfaces for rendering. +type BuildingSurfaces struct { + ObjectID string `json:"object_id"` + DatasetID string `json:"-"` + // OSMIDs are the OSM ways linked to the building, best first; empty when + // it has none. + OSMIDs []string `json:"osm_ids"` + Surfaces []RenderSurface `json:"surfaces"` +} + +// RenderSurface is one surface polygon: the same id as SurfaceGeometry, its +// CityGML class, and a GeoJSON Polygon in EPSG:4326 with Z in metres. +type RenderSurface struct { + ID string `json:"id"` + Type string `json:"type"` + Geometry json.RawMessage `json:"geometry"` +} + +// SurfacePage is one page of SurfacesInBBox. NextCursor is empty on the last page. +type SurfacePage struct { + Buildings []BuildingSurfaces + NextCursor string +} + +// EncodeSurfaceCursor and DecodeSurfaceCursor convert between the last object_id +// of a page and the opaque cursor a caller passes back for the next one. +func EncodeSurfaceCursor(objectID string) string { + return base64.RawURLEncoding.EncodeToString([]byte(objectID)) +} + +// DecodeSurfaceCursor returns the object_id a cursor stands for; "" starts at the +// first page. +func DecodeSurfaceCursor(cursor string) (string, error) { + b, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil || (cursor != "" && len(b) == 0) { + return "", fmt.Errorf("cursor %q is not one this API returned", cursor) + } + return string(b), nil +} + +// SurfacesInBBox returns one page of buildings in cfg's country whose footprint +// intersects bbox, in object_id order after the building afterObjectID names, each +// with its roof and wall surfaces. A page holds whole buildings whose surfaces +// total at most maxVertices vertices, and at least one building. +func SurfacesInBBox(ctx context.Context, pool *pgxpool.Pool, cfg *config.Config, bbox Bbox, afterObjectID string, maxVertices int) (SurfacePage, error) { + buildings, more, err := surfacePageBuildings(ctx, pool, cfg, bbox, afterObjectID, maxVertices) + if err != nil || len(buildings) == 0 { + return SurfacePage{Buildings: buildings}, err + } + if err := attachRenderSurfaces(ctx, pool, cfg, buildings); err != nil { + return SurfacePage{}, err + } + if err := attachOSMIDs(ctx, pool, cfg, buildings); err != nil { + return SurfacePage{}, err + } + page := SurfacePage{Buildings: buildings} + if more { + page.NextCursor = EncodeSurfaceCursor(buildings[len(buildings)-1].ObjectID) + } + return page, nil +} + +// surfacePageBuildings selects the page's buildings by a running total of their +// surface vertices, so no geometry is encoded for buildings left to later pages. +// more reports whether buildings remain after the page. +func surfacePageBuildings(ctx context.Context, pool *pgxpool.Pool, cfg *config.Config, bbox Bbox, after string, maxVertices int) ([]BuildingSurfaces, bool, error) { + q := fmt.Sprintf(` + WITH b AS ( + SELECT object_id, dataset_id FROM %[1]s b + WHERE country_code = $1 AND object_id > $2 + AND building_footprint_geom IS NOT NULL + AND ST_Intersects(building_footprint_geom, ST_Transform(ST_MakeEnvelope($3,$4,$5,$6,4326), $7::int)) + ), + v AS ( + SELECT b.object_id, b.dataset_id, COALESCE(sum(ST_NPoints(s.geom)), 0) AS vertices + FROM b LEFT JOIN %[2]s s ON s.building_object_id = b.object_id AND s.surface_type = ANY($8) + GROUP BY b.object_id, b.dataset_id + ), + c AS ( + SELECT object_id, dataset_id, + sum(vertices) OVER (ORDER BY object_id) AS running, + row_number() OVER (ORDER BY object_id) AS n, + count(*) OVER () AS total + FROM v + ) + SELECT object_id, dataset_id, total FROM c + WHERE running <= $9 OR n = 1 + ORDER BY object_id`, + allLODs(cfg, "building", buildingColumns), allLODs(cfg, "surface", surfaceColumns), + ) + rows, err := pool.Query(ctx, q, cfg.CountryCode, after, bbox.Xmin, bbox.Ymin, bbox.Xmax, bbox.Ymax, + cfg.CityDB.SRID, renderedSurfaceTypes, maxVertices) + if err != nil { + return nil, false, fmt.Errorf("failed to select surface page for %s: %w", cfg.Country, err) + } + defer rows.Close() + + var buildings []BuildingSurfaces + var total int64 + for rows.Next() { + var b BuildingSurfaces + if err := rows.Scan(&b.ObjectID, &b.DatasetID, &total); err != nil { + return nil, false, fmt.Errorf("failed to scan surface page row: %w", err) + } + b.OSMIDs, b.Surfaces = []string{}, []RenderSurface{} + buildings = append(buildings, b) + } + if err := rows.Err(); err != nil { + return nil, false, fmt.Errorf("error iterating surface page rows: %w", err) + } + return buildings, total > int64(len(buildings)), nil +} + +// attachRenderSurfaces fills each building's roof and wall surfaces in one query. +func attachRenderSurfaces(ctx context.Context, pool *pgxpool.Pool, cfg *config.Config, buildings []BuildingSurfaces) error { + byID, ids := indexSurfaceBuildings(buildings) + q := fmt.Sprintf(` + SELECT building_object_id, id::text, surface_type, ST_AsGeoJSON(ST_Transform(geom, 4326), 7) + FROM %s s + WHERE building_object_id = ANY($1) AND surface_type = ANY($2) AND geom IS NOT NULL + ORDER BY building_object_id, id`, + allLODs(cfg, "surface", surfaceColumns), + ) + rows, err := pool.Query(ctx, q, ids, renderedSurfaceTypes) + if err != nil { + return fmt.Errorf("failed to query render surfaces for %s: %w", cfg.Country, err) + } + defer rows.Close() + for rows.Next() { + var objectID, geometry string + var s RenderSurface + if err := rows.Scan(&objectID, &s.ID, &s.Type, &geometry); err != nil { + return fmt.Errorf("failed to scan render surface row: %w", err) + } + s.Geometry = json.RawMessage(geometry) + byID[objectID].Surfaces = append(byID[objectID].Surfaces, s) + } + return rows.Err() +} + +// attachOSMIDs fills each building's linked OSM ways, best first, as +// BuildingsByOSMIDs orders them. +func attachOSMIDs(ctx context.Context, pool *pgxpool.Pool, cfg *config.Config, buildings []BuildingSurfaces) error { + byID, ids := indexSurfaceBuildings(buildings) + q := fmt.Sprintf(` + SELECT object_id, array_agg(osm_id ORDER BY match_type, match_confidence DESC, osm_id) + FROM %s.building_link + WHERE country_code = $1 AND object_id = ANY($2) AND match_type IN (1, 4) + GROUP BY object_id`, + cfg.DB.Schemas.City2Tabula, + ) + rows, err := pool.Query(ctx, q, cfg.CountryCode, ids) + if err != nil { + return fmt.Errorf("failed to query OSM ways for %s: %w", cfg.Country, err) + } + defer rows.Close() + for rows.Next() { + var objectID string + var osmIDs []string + if err := rows.Scan(&objectID, &osmIDs); err != nil { + return fmt.Errorf("failed to scan OSM way row: %w", err) + } + byID[objectID].OSMIDs = osmIDs + } + return rows.Err() +} + +func indexSurfaceBuildings(buildings []BuildingSurfaces) (map[string]*BuildingSurfaces, []string) { + byID := make(map[string]*BuildingSurfaces, len(buildings)) + ids := make([]string, len(buildings)) + for i := range buildings { + byID[buildings[i].ObjectID] = &buildings[i] + ids[i] = buildings[i].ObjectID + } + return byID, ids +} + +// SurfaceDatasetIDs returns the distinct datasets behind buildings, for credits. +func SurfaceDatasetIDs(buildings []BuildingSurfaces) []string { + ids := make([]string, 0, len(buildings)) + for _, b := range buildings { + ids = append(ids, b.DatasetID) + } + return distinct(ids) +} diff --git a/internal/onrequest/surfaces_test.go b/internal/onrequest/surfaces_test.go new file mode 100644 index 0000000..e12ac7f --- /dev/null +++ b/internal/onrequest/surfaces_test.go @@ -0,0 +1,18 @@ +package onrequest + +import "testing" + +func TestSurfaceCursorRoundTrip(t *testing.T) { + for _, id := range []string{"DEBY_LOD2_7672144", "NL.IMBAG.Pand.0150100000000150", "UUID_LOD2_000019-b1f7acdd"} { + got, err := DecodeSurfaceCursor(EncodeSurfaceCursor(id)) + if err != nil || got != id { + t.Errorf("round trip of %q = %q, %v", id, got, err) + } + } + if got, err := DecodeSurfaceCursor(""); err != nil || got != "" { + t.Errorf("empty cursor = %q, %v; want the first page", got, err) + } + if _, err := DecodeSurfaceCursor("%%"); err == nil { + t.Error("malformed cursor decoded without error") + } +} diff --git a/internal/process/surfaces_integration_test.go b/internal/process/surfaces_integration_test.go new file mode 100644 index 0000000..43f539f --- /dev/null +++ b/internal/process/surfaces_integration_test.go @@ -0,0 +1,127 @@ +//go:build integration + +package process_test + +import ( + "context" + "encoding/json" + "slices" + "testing" + + "github.com/thd-spatial-ai/city2tabula/internal/onrequest" +) + +// TestSurfacesInBBox_PagesCoverEveryBuildingOnce pins the bulk surfaces path on +// the real Bavarian seed: paging with a small vertex budget returns every +// building of the bbox exactly once, each page within the budget unless it holds +// a single building, roof and wall surfaces only, as 3D WGS84 polygons carrying +// the same ids as the geometry endpoint. +func TestSurfacesInBBox_PagesCoverEveryBuildingOnce(t *testing.T) { + cfg, _ := setupCorrectionAuditFixture(t) + ctx := context.Background() + bbox := onrequest.Bbox{Xmin: 5, Ymin: 47, Xmax: 15, Ymax: 55} + + want, err := onrequest.BuildingsByBBox(ctx, testPool, cfg, bbox) + if err != nil { + t.Fatalf("BuildingsByBBox: %v", err) + } + var wantIDs []string + for _, b := range want { + wantIDs = append(wantIDs, b.ObjectID) + } + slices.Sort(wantIDs) + wantIDs = slices.Compact(wantIDs) + + const budget = 300 + var gotIDs []string + byID := map[string]onrequest.BuildingSurfaces{} + after, pages := "", 0 + for { + page, err := onrequest.SurfacesInBBox(ctx, testPool, cfg, bbox, after, budget) + if err != nil { + t.Fatalf("SurfacesInBBox page %d: %v", pages+1, err) + } + pages++ + if pages > len(wantIDs)+1 { + t.Fatalf("still paging after %d pages for %d buildings; the cursor does not advance", pages, len(wantIDs)) + } + if len(page.Buildings) == 0 { + t.Fatalf("page %d is empty", pages) + } + vertices := 0 + for _, b := range page.Buildings { + gotIDs = append(gotIDs, b.ObjectID) + byID[b.ObjectID] = b + vertices += checkRenderSurfaces(t, b) + } + if vertices > budget && len(page.Buildings) > 1 { + t.Errorf("page %d holds %d buildings and %d vertices, over the budget of %d", pages, len(page.Buildings), vertices, budget) + } + if page.NextCursor == "" { + break + } + if after, err = onrequest.DecodeSurfaceCursor(page.NextCursor); err != nil { + t.Fatalf("next cursor: %v", err) + } + } + if pages < 2 { + t.Errorf("got %d page with a budget of %d vertices; the fixture should need several", pages, budget) + } + if !slices.Equal(gotIDs, wantIDs) { + t.Errorf("paged buildings (%d) differ from the bbox's buildings (%d)", len(gotIDs), len(wantIDs)) + } + + buildingID := "" + for _, id := range gotIDs { + if len(byID[id].Surfaces) > 0 { + buildingID = id + break + } + } + geometry, err := onrequest.BuildingGeometryByObjectIDs(ctx, testPool, cfg, []string{buildingID}, true, true) + if err != nil || len(geometry) == 0 { + t.Fatalf("BuildingGeometryByObjectIDs(%s): %v", buildingID, err) + } + var geometryIDs []string + for _, s := range geometry[0].Surfaces { + if s.Type == "RoofSurface" || s.Type == "WallSurface" { + geometryIDs = append(geometryIDs, s.ID) + } + } + var surfaceIDs []string + for _, s := range byID[buildingID].Surfaces { + surfaceIDs = append(surfaceIDs, s.ID) + } + slices.Sort(geometryIDs) + slices.Sort(surfaceIDs) + if len(surfaceIDs) == 0 || !slices.Equal(surfaceIDs, geometryIDs) { + t.Errorf("%s: surface ids %v differ from the geometry endpoint's %v", buildingID, surfaceIDs, geometryIDs) + } +} + +// checkRenderSurfaces checks one building's surfaces and returns their vertex count. +func checkRenderSurfaces(t *testing.T, b onrequest.BuildingSurfaces) int { + t.Helper() + vertices := 0 + for _, s := range b.Surfaces { + if s.ID == "" || (s.Type != "RoofSurface" && s.Type != "WallSurface") { + t.Errorf("%s: surface %q of type %q", b.ObjectID, s.ID, s.Type) + } + var g struct { + Type string `json:"type"` + Coordinates [][][]float64 `json:"coordinates"` + } + if err := json.Unmarshal(s.Geometry, &g); err != nil || g.Type != "Polygon" { + t.Fatalf("%s surface %s: geometry %s is not a Polygon: %v", b.ObjectID, s.ID, s.Geometry, err) + } + for _, ring := range g.Coordinates { + vertices += len(ring) + for _, p := range ring { + if len(p) != 3 || p[0] < 5 || p[0] > 15 || p[1] < 47 || p[1] > 55 { + t.Fatalf("%s surface %s: point %v is not a WGS84 point with z in Bavaria", b.ObjectID, s.ID, p) + } + } + } + } + return vertices +}