diff --git a/CMakeLists.txt b/CMakeLists.txt index c3eae01a..a86059ba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ project(mapget LANGUAGES CXX C) # Allow version to be set from command line for CI/CD # For local development, use the default version if(NOT DEFINED MAPGET_VERSION) - set(MAPGET_VERSION 2026.1.3) + set(MAPGET_VERSION 2026.2.0) endif() set(CMAKE_CXX_STANDARD 20) diff --git a/cmake/deps.cmake b/cmake/deps.cmake index c0e1e194..e2684812 100644 --- a/cmake/deps.cmake +++ b/cmake/deps.cmake @@ -34,7 +34,7 @@ if (NOT "${_mapget_simfil_source_dir}" STREQUAL "") "SIMFIL_SHARED OFF") else() CPMAddPackage( - URI "gh:Klebert-Engineering/simfil#v0.7.0" + URI "gh:Klebert-Engineering/simfil@0.7.2" OPTIONS "SIMFIL_WITH_MODEL_JSON ON" "SIMFIL_SHARED OFF") @@ -139,7 +139,7 @@ endif() endif() CPMAddPackage( - URI "gh:jbeder/yaml-cpp#aa8d4e@0.8.0" # Use > 0.8.0 once available. + URI "gh:jbeder/yaml-cpp#yaml-cpp-0.9.0@0.9.0" GIT_SHALLOW OFF OPTIONS "YAML_CPP_BUILD_TESTS OFF" @@ -161,7 +161,7 @@ if (MAPGET_WITH_SERVICE OR MAPGET_WITH_HTTPLIB OR MAPGET_ENABLE_TESTING) endif() if (MAPGET_WITH_WHEEL AND NOT TARGET python-cmake-wheel) - CPMAddPackage("gh:Klebert-Engineering/python-cmake-wheel#v1.2.7@1.2.7") + CPMAddPackage("gh:Klebert-Engineering/python-cmake-wheel#v1.2.8@1.2.8") endif() if (MAPGET_ENABLE_TESTING) diff --git a/docs/mapget-api.md b/docs/mapget-api.md index 3c8479ce..c6bf9c30 100644 --- a/docs/mapget-api.md +++ b/docs/mapget-api.md @@ -80,6 +80,14 @@ To cancel an in-flight HTTP stream, close the HTTP connection. - `Status` frames contain UTF-8 JSON describing per-request `RequestStatus` transitions and a human-readable message. The final status frame has `"allDone": true`. - `LoadStateChange` exists in the protocol but is currently not emitted by the HTTP service. +Each entry in a status frame's `requests` array contains `index`, `mapId`, `layerId`, numeric `status`, and `statusText`. For `NoDataSource` statuses, servers may also include `noDataSourceReason`: + +- `emptySources` +- `allSourcesDisabled` +- `datasourceInitializationFailed` +- `missingMapOrLayer` +- `noConfig` + To cancel, either send a new request message on the same connection (which replaces the current one) or close the WebSocket connection. ## `/tiles/next` – pull binary tile frames @@ -111,11 +119,7 @@ Each line in the JSONL response is a GeoJSON-like FeatureCollection with additio "mapgetTileId": 281479271743500, "mapId": "EuropeHD", "mapgetLayerId": "Roads", - "idPrefix": { - "areaId": 123, - "tileId": 456 - }, - "timestamp": "2025-01-14T10:30:00.000000Z", + "timestamp": 1736850600000000, "ttl": 3600000, "error": { "code": 404, @@ -131,8 +135,7 @@ Each line in the JSONL response is a GeoJSON-like FeatureCollection with additio | `mapgetTileId` | integer | The mapget tile ID (64-bit decimal) | | `mapId` | string | Map identifier | | `mapgetLayerId` | string | Layer identifier within the map | -| `idPrefix` | object | Common ID parts shared by all features in this tile (optional) | -| `timestamp` | string | ISO 8601 timestamp when the tile was created | +| `timestamp` | integer | Tile creation time in microseconds since the Unix epoch | | `ttl` | integer | Time-to-live in milliseconds (optional) | | `error` | object | Error information if tile creation failed (optional) | | `error.code` | integer | Numeric error code, e.g., HTTP status or database error (optional) | @@ -238,10 +241,17 @@ The page shows the number of active datasources and worker threads, cache statis The response contains: - `timestampMs` -- `service`: service statistics, datasource info, cache occupancy, and optional tile-size-distribution data +- `service`: service statistics, datasource info, cache occupancy, datasource-config counts, and optional tile-size-distribution data - `cache`: cache hit/miss counters and cache sizes - `tilesWebsocket`: control-channel metrics such as active sessions, pending queued frames for `/tiles/next`, blocked pull requests, and total forwarded bytes / frames +`service.datasource-config` reports datasource YAML load diagnostics: + +- `configured`: number of entries under `sources`. +- `enabled`: number of entries not disabled by `enabled: false`. +- `disabled`: number of entries skipped because `enabled: false`. +- `construction-failed`: number of enabled entries whose datasource construction failed. + ## `/locate` – resolve external feature IDs `POST /locate` resolves external feature references to the tile IDs and feature IDs that contain them. This is commonly used together with feature search results or external databases that store map references. @@ -262,7 +272,7 @@ Datasources are free to implement more advanced resolution schemes (for example ## `/config` – inspect and update configuration -The `/config` endpoint family exposes the YAML configuration used by `mapget` for datasource wiring and HTTP settings. It is optional and can be enabled or disabled from the command line. +The `/config` endpoint family exposes the YAML configuration used by `mapget` for datasource wiring and HTTP settings. Command-line flags control whether datasource config is exposed and whether updates are accepted. @@ -271,21 +281,37 @@ The `/config` endpoint family exposes the YAML configuration used by `mapget` fo - **Method:** `GET` - **Request body:** none - **Response:** `application/json` object with the keys: - - `model`: JSON representation of the current YAML config, limited to the `sources` and `http-settings` top‑level keys. - - `schema`: JSON Schema used to validate incoming configurations. + - `schema`: JSON Schema used to validate datasource-model configurations. + - `model`: JSON representation of the current YAML config, limited to top-level keys in the active datasource schema. The built-in schema includes `sources`; deployments can add keys such as `http-settings` through `--config-schema`. - `readOnly`: boolean flag indicating whether `POST /config` is enabled. + - `datasourceConfigUnavailable`: boolean flag indicating that datasource config could not or must not be exposed. + - `datasourceConfigUnavailableReason`: `null` on success, otherwise a stable reason string. + - Additional public sections registered by the embedding application, returned as top-level siblings of `model`. + +When the endpoint handler is reached, `GET /config` returns HTTP `200`. `readOnly` reflects whether `POST /config` is enabled. If `--no-get-config` is set, `datasourceConfigUnavailable` is `true`, `datasourceConfigUnavailableReason` is `getConfigDisabled`, and `model` is empty. In that state, writable servers still return `schema` so clients can present an empty replacement editor; read-only servers return an empty schema. + +Unavailable reason values are: + +- `getConfigDisabled` +- `configPathUnset` +- `configFileMissing` +- `configFileOpenFailed` +- `configParseFailed` +- `configValidationFailed` + +On a successful datasource-config response, `datasourceConfigUnavailable` is `false` and `datasourceConfigUnavailableReason` is `null`. The returned model masks sensitive fields: any `password` or `api-key` values are replaced with stable masked tokens. -This call is disabled if the server is started with `--no-get-config`. When enabled, it provides a safe way for tools and UIs to read the active configuration without exposing secrets in plain text: any `password` or `api-key` fields are replaced with stable masked tokens. +Registered public sections are read-only. They are included as top-level siblings of `model` when the YAML config can be read and parsed, even if the datasource model itself is hidden through `--no-get-config`. If the YAML config cannot be read or parsed, registered public sections are still present but empty. ### `POST /config` - **Method:** `POST` - **Request body:** `application/json` matching the schema returned by `GET /config`. - - Must contain both `sources` and `http-settings` keys at the top level. + - Must contain the datasource-model keys required by the schema. - **Response:** - `text/plain` success message when the configuration was validated, written to disk and successfully applied. - `text/plain` error description and a 4xx/5xx status code if validation or application failed. -This call is only accepted if the server is started with `--allow-post-config`. When a valid configuration is posted, mapget rewrites the underlying YAML file, preserving real secret values where masked tokens were supplied, and then reloads the datasource configuration. Clients should be prepared for temporary 5xx errors if reloading fails. +This call is only accepted if the server is started with `--allow-post-config`. When a valid configuration is posted, mapget rewrites the datasource-model fields in the underlying YAML file, preserving real secret values where masked tokens were supplied, and then reloads the datasource configuration. Unknown top-level YAML sections, including registered public sections, are preserved but not edited through this endpoint. Clients should be prepared for temporary 5xx errors if reloading fails. diff --git a/docs/mapget-config.md b/docs/mapget-config.md index 9fa54eb8..48612a5a 100644 --- a/docs/mapget-config.md +++ b/docs/mapget-config.md @@ -22,7 +22,9 @@ Two top‑level keys are relevant for mapget itself: For integration with configuration UIs there is an additional top‑level key: -- `http-settings` (optional) stores HTTP‑related settings used by frontends or tooling. Mapget itself does not interpret its contents, but exposes it via `/config` and ensures that sensitive fields such as `password` and `api-key` are masked in responses. +- `http-settings` (optional) stores HTTP‑related settings used by frontends or tooling. Mapget itself does not interpret its contents. It is exposed through `/config.model` only when the active datasource schema includes `http-settings`, typically via a deployment-specific `--config-schema` patch. + +Embedded applications can also register additional public top-level sections for `GET /config`. These sections are returned as siblings of `model`, not inside `model`, and are outside mapget's datasource schema. For example, MapViewer registers an `erdblick` section for frontend defaults. `POST /config` remains scoped to datasource-model keys and preserves unknown public sections in the YAML file. Changes to the `sources` section take effect while the server is running. Changes to options under `mapget` only apply after the server is restarted. @@ -35,10 +37,19 @@ By default, the HTTP service registers the following datasource types: - `DataSourceHost` – connect to a remote `DataSourceServer` over HTTP. - `DataSourceProcess` – spawn a datasource process locally and connect to it. - `GridDataSource` – generate synthetic tiles on the fly for testing and benchmarking. -- `GeoJsonFolder` – serve features from a directory containing `.geojson` files named by tile ID. +- `GeoJsonFolder` – serve features from local GeoJSON files. +- `GeoJsonEndpoint` – serve features from GeoJSON files fetched over HTTP. Additional datasource types can be registered from C++ code using `DataSourceConfigService`, but those are outside the scope of this guide. +Every datasource entry accepts these generic fields in addition to type-specific fields: + +- `enabled` (optional, default `true`): when set to `false`, the entry is skipped before its type-specific constructor is called. +- `ttl` (optional): cache time-to-live override in seconds. `0` means infinite. +- `auth-header` (optional): header-to-regular-expression map as described below. + +Disabled entries are counted separately in service diagnostics and are not treated as construction failures. + ### Restricting access with `auth-header` @@ -205,6 +216,8 @@ Optional fields: - `mapId`: optional map ID override. If omitted, mapget derives a display name from the input directory. - `withAttrLayers` (default: `true`): boolean flag. If `true`, nested objects in the GeoJSON `properties` are converted to mapget attribute layers; if `false`, only scalar top‑level properties are emitted and nested objects are silently dropped. +- `dataSourceInfo`: inline datasource info object, local YAML/JSON file path, or HTTP(S) URL. +- `tilePathTemplate`: relative path template used when `dataSourceInfo` is configured. Supported placeholders are `{tileId}` and `{layerId}`. Example: @@ -216,9 +229,26 @@ sources: withAttrLayers: true ``` -#### Manifest Mode (Recommended) +Example with explicit layer metadata and template-based file lookup: + +```yaml +sources: + - type: GeoJsonFolder + folder: /data/tiles + dataSourceInfo: /data/tiles/info.yaml + tilePathTemplate: "{layerId}/{tileId}.geojson" +``` + +If `dataSourceInfo` is omitted, mapget falls back to legacy discovery: + +- first it looks for a legacy `manifest.json` +- if no manifest exists, it scans the folder for files named `.geojson` -If a `manifest.json` file exists in the input directory, it is used to map filenames to tile IDs and layers. This allows arbitrary filenames and multi‑layer support. +In that fallback mode, metadata is synthesized as a single `GeoJsonAny` layer and conversion is best-effort. + +#### Manifest Mode (Legacy) + +If no explicit `dataSourceInfo` is configured and a `manifest.json` file exists in the input directory, it is used to map filenames to tile IDs and layers. This allows arbitrary filenames and multi‑layer support. **Manifest Structure:** @@ -276,11 +306,39 @@ If no `manifest.json` exists, the datasource falls back to scanning for files na +### `GeoJsonEndpoint` + +`GeoJsonEndpoint` serves GeoJSON tiles from an HTTP(S) endpoint. It uses the same GeoJSON conversion logic as `GeoJsonFolder`, but fetches each tile body over the network. + +Required fields: + +- `type`: must be `GeoJsonEndpoint`. +- `baseUrl`: base HTTP(S) URL used to fetch GeoJSON tiles. + +Optional fields: + +- `mapId`: optional map ID override. If omitted, mapget derives a display name from the base URL. +- `withAttrLayers` (default: `true`): converts nested GeoJSON property objects to mapget attribute layers. +- `dataSourceInfo`: inline datasource info object, local YAML/JSON file path, or HTTP(S) URL. +- `tileUrlTemplate`: URL or relative path template used to fetch tiles. Supported placeholders are `{tileId}`, `{layerId}`, and `{baseUrl}`. + +Example: + +```yaml +sources: + - type: GeoJsonEndpoint + baseUrl: https://example.test/tiles + dataSourceInfo: https://example.test/info.yaml + tileUrlTemplate: "{layerId}/{tileId}.geojson" +``` + +If `dataSourceInfo` is omitted, mapget emits a strong warning and falls back to a synthesized single-layer `GeoJsonAny` datasource with empty coverage. In that fallback mode, conversion is still attempted, but service discovery remains intentionally limited. + ## HTTP settings for tools and UIs The optional `http-settings` top‑level key is reserved for HTTP‑related configuration used by tools and user interfaces. It is typically a list of objects that may contain fields such as `scope`, `api-key` or `password`. -Mapget itself treats this section as opaque data: it is read and written via the `/config` endpoint but not interpreted when serving tiles. When returning the configuration, mapget replaces the values of any `api-key` or `password` fields with masked tokens. When a modified configuration is posted back, these tokens are resolved to the original secret values before the YAML file is updated. +Mapget itself treats this section as opaque data and does not interpret it when serving tiles. It is included in `/config.model` only when the active datasource schema contains an `http-settings` property, for example through a deployment-specific `--config-schema` patch. When returning the configuration, mapget replaces the values of any `api-key` or `password` fields with masked tokens. When a modified configuration is posted back, these tokens are resolved to the original secret values before the YAML file is updated. ## Environment variables @@ -313,7 +371,10 @@ mapget: cache-max-tiles: 20000 # --cache-max-tiles (0 disables the limit) clear-cache: false # --clear-cache allow-post-config: true # --allow-post-config (enables POST /config) - no-get-config: false # --no-get-config (set to true to disable GET /config) + no-get-config: false # --no-get-config (hides datasource model in GET /config) + webapp: /srv/my-ui # --webapp, one application document root + static-mount: # --static-mount, additional static aliases + - /assets:/srv/assets memory-trim-binary-interval: 100 # --memory-trim-binary-interval memory-trim-json-interval: 0 # --memory-trim-json-interval @@ -321,6 +382,15 @@ http-settings: ... sources: ... ``` -Adjust or omit fields as needed; unspecified options fall back to the same defaults as the CLI flags (for example, in-memory cache, port 0, GET `/config` enabled, POST `/config` disabled). +Adjust or omit fields as needed; unspecified options fall back to the same defaults as the CLI flags (for example, in-memory cache, port 0, GET `/config` enabled, POST `/config` disabled). Static mount entries use `[:]` syntax and are served as plain files; mapget does not attach application-specific meaning to those files. + +Datasource editor visibility is controlled by `allow-post-config` and `no-get-config`: + +| `allow-post-config` | `no-get-config` | `/config` datasource model | Editor behaviour | +| --- | --- | --- | --- | +| `false` | `false` | Current model is returned. | Read-only. | +| `true` | `false` | Current model is returned. | Editable. | +| `false` | `true` | Model and schema are empty. | Disabled. | +| `true` | `true` | Model is empty, schema is returned. | Empty editor; applying overwrites datasource config. | diff --git a/docs/mapget-dev-guide.md b/docs/mapget-dev-guide.md index 2a66f424..09163b20 100644 --- a/docs/mapget-dev-guide.md +++ b/docs/mapget-dev-guide.md @@ -221,6 +221,8 @@ For interactive clients, tile streaming uses WebSocket `GET /tiles` as a control - map HTTP/WebSocket endpoints to service calls (`/sources`, `/tiles`, `/tiles/next`, `/status`, `/status-data`, `/locate`, `/config`), - parse JSON requests and build `LayerTilesRequest` objects, - serialize tile responses as JSONL or binary streams, +- mount static filesystem roots configured through `--webapp` and `--static-mount`, +- expose `mapget::ensureStaticMount()` and `mapget::removeStaticMount()` for embedding applications that discover additional static roots after startup, - provide `/config` as a JSON view on the YAML config file. ### Tile streaming @@ -249,10 +251,12 @@ This means `priorityTileIds` affects both backend scheduling and already-produce The HTTP service also implements `/config`: -- `GET /config` reads the YAML configuration file, extracts the `sources` and `http-settings` keys, masks any `password` or `api-key` values and returns the result alongside the JSON Schema configured via `--config-schema`. -- `POST /config` validates an incoming JSON document against that schema, merges it back into the YAML file (unmasking secrets) and relies on `DataSourceConfigService` to apply the updated datasource configuration. +- `GET /config` reads the YAML configuration file, extracts top-level keys from the active datasource schema, masks any `password` or `api-key` values and returns the result alongside that schema. The built-in schema includes `sources`; deployments can add keys such as `http-settings` via `--config-schema`. +- `GET /config` also merges public read-only sections registered through `DataSourceConfigService::registerPublicConfigSection(...)` as top-level siblings of `model`. Mapget does not interpret those sections itself. +- If datasource configuration cannot be exposed, `GET /config` still returns HTTP `200` from the handler and reports the problem through `datasourceConfigUnavailable` plus `datasourceConfigUnavailableReason`. Public read-only sections are still returned when the YAML config can be read and parsed. With `--no-get-config --allow-post-config`, the datasource model is empty but writable so clients can post a replacement configuration. +- `POST /config` validates an incoming datasource-model JSON document against the schema, merges it back into the YAML file (unmasking secrets), preserves unknown top-level sections, and relies on `DataSourceConfigService` to apply the updated datasource configuration. -These endpoints are guarded by command‑line flags: `--no-get-config` disables the GET endpoint, and `--allow-post-config` enables the POST endpoint. They are primarily intended for configuration editors and admin tools. +These endpoints are guarded by command-line flags: `--no-get-config` hides the datasource model from `GET /config`, and `--allow-post-config` enables the POST endpoint. They are primarily intended for configuration editors and admin tools. ## Binary streaming and simfil integration diff --git a/docs/mapget-model.md b/docs/mapget-model.md index 27d044b1..d87bb445 100644 --- a/docs/mapget-model.md +++ b/docs/mapget-model.md @@ -114,16 +114,15 @@ flowchart LR - `LayerInfo.stages` declares how many stages exist for a layer. `stageLabels` are presentation metadata only. `highFidelityStage` is the actual rule-fidelity cutover used by consumers: stages below it are low-fidelity, stages at/above it are high-fidelity. - Clients request staged tiles with `tileIdsByNextStage`: bucket `i` contains tiles whose next missing stage is `i`. The service expands each tile to the remaining stages for that layer. -- Plain `tileIds` are an unstaged request form. They do not mean “bucket 0 only”; they mean “request this tile without stage-bucket expansion”. -- Therefore a staged client must preserve `tileIdsByNextStage` even when only bucket `0` is populated. - Payload partitioning is datasource-defined. In current `mapget-live-cpp`, the common patterns are: - - `SINGLE_STAGE`: stage `0` carries the complete feature payload. - - `GEOMETRY_THEN_ATTRIBUTES`: stage `0` carries full geometry/internal relations, stage `1` carries non-ADAS attributes and relations. - - `LOW_FI_HIGH_FI_ADAS`: stage `0` carries the low-fidelity geometry payload, stage `1` carries full geometry plus non-ADAS enrichment, stage `2` carries ADAS-only enrichment. - - `LOW_FI_FULL_GEOM_HIGH_FI_ADAS`: stage `0` already carries the canonical base geometry, stage `1` adds non-ADAS enrichment, stage `2` adds ADAS-only enrichment. + ``` + SINGLE_STAGE: stage `0` carries the complete feature payload. + GEOMETRY_THEN_ATTRIBUTES: stage `0` carries full geometry/internal relations, stage `1` carries non-ADAS attributes and relations. + LOW_FI_HIGH_FI_ADAS: stage `0` carries the low-fidelity geometry payload, stage `1` carries full geometry plus non-ADAS enrichment, stage `2` carries ADAS-only enrichment. + LOW_FI_FULL_GEOM_HIGH_FI_ADAS: stage `0` already carries the canonical base geometry, stage `1` adds non-ADAS enrichment, stage `2` adds ADAS-only enrichment. + ``` - A consequence of the last two patterns: stage number and stage label do not, by themselves, tell you whether a stage is “high fidelity”. Use `highFidelityStage` instead. - Each feature also carries a backend `lod` (`LOD_0..LOD_7`). This is independent of stage: a stage answers “which payload slice arrived?”, while `lod` answers “how aggressively may a low-fidelity renderer cull this feature?”. -- `TileFeatureLayer::newFeature(...)` defaults stage-`0` features to `LOD_0` and later-stage feature records to `MAX_LOD`. Converters may override the stage-`0` value semantically (for example by road class). During stage merge, the stage-`0` feature data remains authoritative for `lod`. ## Feature IDs diff --git a/docs/mapget-setup.md b/docs/mapget-setup.md index b26b1c29..ece31930 100644 --- a/docs/mapget-setup.md +++ b/docs/mapget-setup.md @@ -53,6 +53,21 @@ mapget --config examples/config/sample-service.yaml serve When `--config` is used, the HTTP service subscribes to the referenced YAML file. Any changes to its `sources` section are picked up while the server is running. Details of that file format are described in `mapget-config.md`. +## Serving static files + +`mapget serve` can expose static filesystem directories in addition to datasource endpoints: + +```bash +mapget serve \ + --port 8080 \ + --webapp /srv/my-ui \ + --static-mount /assets:/srv/assets +``` + +`--webapp` is the single application document root, usually mounted at `/`. `--static-mount` is for additional static aliases and can be specified multiple times. Both options use `[:]` syntax; if the URL scope is omitted, the directory is mounted at `/`. + +Static mounts are generic HTTP serving functionality. Mapget does not interpret or validate the files beyond requiring the filesystem path to exist and be a directory. Higher-level applications such as MapViewer may use static mounts for their own assets, but application-specific configuration semantics live in those applications, not in mapget. Embedded applications that discover static roots after startup can call `mapget::ensureStaticMount()` instead of synthesizing command-line options. + ## Using the fetch client The `fetch` subcommand is a simple HTTP client for `/tiles`. It requests one map layer for a set of tiles and prints each tile as a JSON feature collection. @@ -78,4 +93,3 @@ For local experimentation it is often useful to increase log verbosity. You can - `MAPGET_LOG_FILE_MAXSIZE` limits the size of a log file before it is rotated. These settings apply to both the Python entry point and the native executable and are especially handy when debugging datasources or cache behaviour. - diff --git a/docs/mapget-user-guide.md b/docs/mapget-user-guide.md index 5385df53..293c7a38 100644 --- a/docs/mapget-user-guide.md +++ b/docs/mapget-user-guide.md @@ -9,7 +9,7 @@ Most readers will only need the setup and API chapters. The model and configurat The guide is split into several focused documents: - [**Setup Guide**](mapget-setup.md) explains how to install mapget via `pip`, how to build the native executable from source, and how to start a server or use the built‑in `fetch` client for quick experiments. -- [**Configuration Guide**](mapget-config.md) documents the YAML configuration file used with `--config`, the supported datasource types (`DataSourceHost`, `DataSourceProcess`, `GridDataSource`, `GeoJsonFolder) and the optional `http-settings` section used by tools and UIs. +- [**Configuration Guide**](mapget-config.md) documents the YAML configuration file used with `--config`, the supported datasource types (`DataSourceHost`, `DataSourceProcess`, `GridDataSource`, `GeoJsonFolder`, `GeoJsonEndpoint`), generic datasource fields such as `enabled`, and optional UI-facing sections such as `http-settings`. - [**HTTP / WebSocket API Guide**](mapget-api.md) describes the endpoints exposed by `mapget serve`, including `/sources`, `/tiles`, `/tiles/next`, `/status`, `/status-data`, `/locate` and `/config`, along with their request and response formats and example calls. - [**Caching Guide**](mapget-cache.md) covers the available cache modes (`memory`, `persistent`, `none`), explains how to configure cache size and location, and shows how to inspect cache statistics via the status endpoint. - [**Simfil Language Extensions**](mapget-simfil-extensions.md) introduces the feature model, tiling scheme, geometry and validity concepts, and the binary tile stream format. This chapter is especially relevant if you are writing datasources or low‑level clients. diff --git a/examples/python/datasource.py b/examples/python/datasource.py index 185f87cb..214baef5 100644 --- a/examples/python/datasource.py +++ b/examples/python/datasource.py @@ -48,13 +48,47 @@ def handle_tile_request(tile: mapget.TileFeatureLayer): # Add an attribute layer attr_layer: mapget.Object = feature.attribute_layers().new_layer("rules") attr: mapget.Attribute = attr_layer.new_attribute("SPEED_LIMIT") - # TODO: Add Python bindings for validities. - # attr.set_direction(mapget.Direction.POSITIVE) + attr.validity().new_offset_range( + mapget.ValidityGeometryOffsetType.RELATIVE_LENGTH, + 0.0, + 1.0, + direction=mapget.Direction.POSITIVE) attr.add_field("speedLimit", 50) - # Add a child feature ID - # TODO: Add Python bindings for relations. - # feature.children().append(tile.new_feature_id("Way", [("wayId", 10)])) + # Add a feature relation + target = tile.new_feature_id("Way", [("wayId", 10)]) + relation = feature.add_relation("successor", target) + relation.source_validity().new_complete() + + # Attach source-data provenance. + refs = tile.new_source_data_references([ + ("RawWayLayer", "primary", mapget.SourceDataAddress(0, 128)) + ]) + feature.set_source_data_references(refs) + + +def handle_source_data_request(tile: mapget.TileSourceDataLayer): + compound = tile.new_compound(2) + compound.set_schema_name("example.RawWay") + compound.set_source_data_address(mapget.SourceDataAddress(0, 128)) + compound.add_field("wayId", 0) + compound.add_field("name", "Main St.") + tile.add_root(compound) + + +def handle_locate_request(request: mapget.LocateRequest): + response = mapget.LocateResponse(request) + response.tile_key = mapget.MapTileKey( + mapget.LayerType.FEATURES, + request.map_id, + "WayLayer", + mapget.TileId(12345), + 0) + return [response] + + +def handle_cache_expired(tile_key: mapget.MapTileKey, expired_at_us: int): + print(f"Cached tile expired: {tile_key} at {expired_at_us}us.") # Instantiate a data source with a minimal mandatory set @@ -71,12 +105,18 @@ def handle_tile_request(tile: mapget.TileFeatureLayer): }]] } ] + }, + "RawWayLayer": { + "type": "SourceData" } }, "mapId": "TestMap" }) # Set the callback which is invoked when a tile is requested. ds.on_tile_feature_request(handle_tile_request) +ds.on_tile_sourcedata_request(handle_source_data_request) +ds.on_locate_request(handle_locate_request) +ds.on_cache_expired(handle_cache_expired) # Parse port as optional first argument port = 0 # Pick random free port diff --git a/libs/geojsonsource/CMakeLists.txt b/libs/geojsonsource/CMakeLists.txt index 9c0b1721..4c1e3148 100644 --- a/libs/geojsonsource/CMakeLists.txt +++ b/libs/geojsonsource/CMakeLists.txt @@ -10,8 +10,10 @@ target_include_directories(geojsonsource target_link_libraries(geojsonsource PUBLIC + drogon mapget-model - mapget-service) + mapget-service + zlibstatic) if (MSVC) target_compile_definitions(geojsonsource diff --git a/libs/geojsonsource/include/geojsonsource/geojsonsource.h b/libs/geojsonsource/include/geojsonsource/geojsonsource.h index b2e046fd..a71a73b8 100644 --- a/libs/geojsonsource/include/geojsonsource/geojsonsource.h +++ b/libs/geojsonsource/include/geojsonsource/geojsonsource.h @@ -1,29 +1,33 @@ #pragma once -#include -#include -#include #include +#include +#include +#include +#include +#include #include "mapget/model/featurelayer.h" #include "mapget/model/sourcedatalayer.h" #include "mapget/service/datasource.h" +#include + namespace mapget::geojsonsource { /** - * Entry describing a single GeoJSON file in the manifest. + * Entry describing a single GeoJSON file in the legacy manifest. */ struct FileEntry { std::string filename; uint64_t tileId = 0; - std::string layer; // Empty means use default layer + std::string layer; }; /** - * Metadata section of the manifest (all fields optional). + * Metadata section of the legacy GeoJSON manifest. */ struct ManifestMetadata { @@ -36,7 +40,7 @@ struct ManifestMetadata }; /** - * Parsed manifest.json structure. + * Parsed legacy manifest.json structure. */ struct Manifest { @@ -47,115 +51,132 @@ struct Manifest }; /** - * Key for looking up files by (tileId, layer). + * Key for looking up legacy manifest files by tile and layer. */ struct TileLayerKey { - uint64_t tileId; + uint64_t tileId = 0; std::string layer; - bool operator==(const TileLayerKey& other) const { + bool operator==(const TileLayerKey& other) const + { return tileId == other.tileId && layer == other.layer; } }; struct TileLayerKeyHash { - std::size_t operator()(const TileLayerKey& k) const { - return std::hash()(k.tileId) ^ (std::hash()(k.layer) << 1); + std::size_t operator()(const TileLayerKey& key) const + { + return std::hash()(key.tileId) ^ (std::hash()(key.layer) << 1); } }; /** - * Data Source which may be used to load GeoJSON files from a directory. + * Optional overrides for GeoJsonFolder. * - * Supports two modes of operation: - * - * 1. **Manifest mode** (recommended): If a `manifest.json` file exists in the - * input directory, it is used to map filenames to tile IDs and layers. - * This allows arbitrary filenames and multi-layer support. - * - * Example manifest.json: - * ```json - * { - * "version": 1, - * "metadata": { - * "name": "My Dataset", - * "source": "OpenStreetMap", - * "created": "2024-01-15" - * }, - * "index": { - * "defaultLayer": "GeoJsonAny", - * "files": { - * "roads.geojson": { "tileId": 121212121212, "layer": "Road" }, - * "lanes.geojson": { "tileId": 121212121212, "layer": "Lane" }, - * "other.geojson": { "tileId": 343434343434 } - * } - * } - * } - * ``` + * `dataSourceInfoJson` and `dataSourceInfoLocation` are mutually exclusive. + * The location may point to a local YAML/JSON file or to an HTTP(S) URL. + */ +struct GeoJsonSourceOptions +{ + bool withAttrLayers = true; + std::string mapId; + std::string tilePathTemplate; + std::optional dataSourceInfoJson; + std::string dataSourceInfoLocation; +}; + +/** + * Optional overrides for GeoJsonEndpoint. * - * 2. **Legacy mode**: If no manifest.json exists, falls back to scanning for - * files named `.geojson`. All files go into a single - * "GeoJsonAny" layer. + * `dataSourceInfoJson` and `dataSourceInfoLocation` are mutually exclusive. + * The location may point to a local YAML/JSON file or to an HTTP(S) URL. + */ +struct GeoJsonEndpointSourceOptions +{ + std::string baseUrl; + bool withAttrLayers = true; + std::string mapId; + std::string tileUrlTemplate; + std::optional dataSourceInfoJson; + std::string dataSourceInfoLocation; + + /** + * Optional text fetch override used for tests or custom transports. + * When unset, HTTP(S) URLs are fetched via the built-in HTTP client. + */ + std::function fetchText; +}; + +/** + * Data source which loads GeoJSON tiles from a local folder. * - * Note: This data source was mainly developed as a scalability test - * scenario for erdblick. In the future, the DBI will export the same - * GeoJSON feature model that is understood by mapget, and a GeoJSON - * data source will be part of the mapget code base. + * The datasource supports three modes: + * - explicit `dataSourceInfo` plus `tilePathTemplate` + * - legacy `manifest.json` filename mapping + * - legacy directory scanning for `.geojson` */ class GeoJsonSource : public mapget::DataSource { public: - /** - * Construct a GeoJSON data source from a directory. - * - * If a manifest.json exists in the directory, it will be used for - * file-to-tile mapping and layer configuration. Otherwise, falls back - * to legacy mode where files must be named `.geojson`. - * - * @param inputDir The directory with the GeoJSON files (and optional manifest.json). - * @param withAttrLayers Flag indicating whether compound GeoJSON - * properties shall be converted to mapget attribute layers. - * @param mapId Optional map ID override. If empty, derived from inputDir. - */ - GeoJsonSource(const std::string& inputDir, bool withAttrLayers, const std::string& mapId=""); + GeoJsonSource( + const std::string& inputDir, + bool withAttrLayers, + const std::string& mapId = ""); + explicit GeoJsonSource(std::string inputDir, GeoJsonSourceOptions options); - /** DataSource Interface */ mapget::DataSourceInfo info() override; void fill(mapget::TileFeatureLayer::Ptr const&) override; void fill(mapget::TileSourceDataLayer::Ptr const&) override; - /** Returns true if a manifest.json was found and used. */ [[nodiscard]] bool hasManifest() const { return hasManifest_; } - - /** Returns the parsed manifest (only valid if hasManifest() is true). */ [[nodiscard]] const Manifest& manifest() const { return manifest_; } private: - /** Parse manifest.json from the input directory. Returns true if found and valid. */ - bool parseManifest(); - - /** Initialize coverage from manifest entries. */ + [[nodiscard]] bool parseManifest(); void initFromManifest(); - - /** Initialize coverage by scanning directory for .geojson files (legacy). */ void initFromDirectory(); - - /** Create LayerInfo JSON for a given layer name. */ - static nlohmann::json createLayerInfoJson(const std::string& layerName); + [[nodiscard]] std::string resolveTilePath(uint64_t tileId, std::string_view layerId) const; + [[nodiscard]] std::string readTileBody(uint64_t tileId, std::string_view layerId) const; + [[nodiscard]] static nlohmann::json createLayerInfoJson(const std::string& layerName); mapget::DataSourceInfo info_; std::string inputDir_; bool withAttrLayers_ = true; bool hasManifest_ = false; + bool usesTemplatePaths_ = false; + std::string tilePathTemplate_; Manifest manifest_; - - // Mapping from (tileId, layer) -> filename std::unordered_map tileLayerToFile_; - - // Set of covered tile IDs per layer (for legacy single-layer mode compatibility) std::unordered_map> layerCoverage_; }; +/** + * Data source which loads GeoJSON tiles from an HTTP endpoint. + */ +class GeoJsonEndpointSource : public mapget::DataSource +{ +public: + explicit GeoJsonEndpointSource(GeoJsonEndpointSourceOptions options); + ~GeoJsonEndpointSource(); + + mapget::DataSourceInfo info() override; + void fill(mapget::TileFeatureLayer::Ptr const&) override; + void fill(mapget::TileSourceDataLayer::Ptr const&) override; + +private: + [[nodiscard]] std::string renderTileUrl(uint64_t tileId, std::string_view layerId) const; + [[nodiscard]] std::string fetchTileBody(uint64_t tileId, std::string_view layerId) const; + + mapget::DataSourceInfo info_; + std::string baseUrl_; + std::string tileUrlTemplate_; + bool withAttrLayers_ = true; + std::function fetchText_; + + struct Impl; + std::unique_ptr impl_; +}; + } // namespace mapget::geojsonsource diff --git a/libs/geojsonsource/src/geojsonsource.cpp b/libs/geojsonsource/src/geojsonsource.cpp index 5fa8be20..8f22d05b 100644 --- a/libs/geojsonsource/src/geojsonsource.cpp +++ b/libs/geojsonsource/src/geojsonsource.cpp @@ -3,52 +3,344 @@ #include "geojsonsource/geojsonsource.h" #include "mapget/log.h" -#include "mapget/model/sourcedatalayer.h" +#include "mapget/service/config.h" -#include "nlohmann/json.hpp" -#include "fmt/format.h" +#include +#include +#include +#include +#include +#include #include #include -#include +#include #include #include +#include "fmt/format.h" + +#include + namespace { -simfil::ModelNode::Ptr jsonToMapget( // NOLINT (recursive) - mapget::TileFeatureLayer::Ptr const& tfl, - const nlohmann::json& j) -{ - if (j.is_string()) - return tfl->newValue(j.get()); - if (j.is_number_integer()) - return tfl->newValue(j.get()); - if (j.is_number_float()) - return tfl->newValue(j.get()); - if (j.is_boolean()) - return tfl->newSmallValue(j.get()); - if (j.is_null()) - return {}; - if (j.is_object()) { - auto subObject = tfl->newObject(j.size(), true); - for (auto& el : j.items()) - subObject->addField(el.key(), jsonToMapget(tfl, el.value())); - return subObject; +using namespace mapget; + +constexpr auto MANIFEST_FILENAME = "manifest.json"; + +[[nodiscard]] int defaultParallelJobs() +{ + const auto hardwareThreads = static_cast(std::thread::hardware_concurrency()); + return std::max(static_cast(0.33 * std::max(hardwareThreads, 1)), 2); +} + +[[nodiscard]] std::string_view cleartextWebScheme() +{ + static constexpr char scheme[] = {'h', 't', 't', 'p', '\0'}; + return {scheme, 4}; +} + +[[nodiscard]] std::string_view tlsWebScheme() +{ + static constexpr char scheme[] = {'h', 't', 't', 'p', 's', '\0'}; + return {scheme, 5}; +} + +[[nodiscard]] bool hasUrlScheme(std::string_view value, std::string_view scheme) +{ + constexpr std::string_view delimiter = "://"; + return value.size() > scheme.size() + delimiter.size() && + value.compare(0, scheme.size(), scheme) == 0 && + value.compare(scheme.size(), delimiter.size(), delimiter) == 0; +} + +[[nodiscard]] bool looksLikeHttpUrl(std::string_view value) +{ + return hasUrlScheme(value, cleartextWebScheme()) || hasUrlScheme(value, tlsWebScheme()); +} + +[[nodiscard]] std::string trimmed(std::string value) +{ + while (!value.empty() && std::isspace(static_cast(value.front()))) + value.erase(value.begin()); + while (!value.empty() && std::isspace(static_cast(value.back()))) + value.pop_back(); + return value; +} + +[[nodiscard]] std::string replaceAll( + std::string text, + std::string_view needle, + std::string_view replacement) +{ + if (needle.empty()) + return text; + + std::size_t pos = 0; + while ((pos = text.find(needle, pos)) != std::string::npos) { + text.replace(pos, needle.size(), replacement); + pos += replacement.size(); + } + return text; +} + +[[nodiscard]] std::string renderTemplate( + std::string const& input, + uint64_t tileId, + std::string_view layerId, + std::string_view baseUrl = {}) +{ + auto result = replaceAll(input, "{tileId}", std::to_string(tileId)); + result = replaceAll(result, "{layerId}", layerId); + if (!baseUrl.empty()) + result = replaceAll(result, "{baseUrl}", baseUrl); + return result; +} + +struct ParsedHttpUrl +{ + std::string origin; + std::string pathAndQuery; +}; + +[[nodiscard]] ParsedHttpUrl splitHttpUrl(std::string_view url) +{ + if (!looksLikeHttpUrl(url)) + raise(fmt::format("Expected HTTP(S) URL, got `{}`.", url)); + + auto schemeEnd = url.find("://"); + auto pathStart = url.find('/', schemeEnd + 3); + if (pathStart == std::string_view::npos) + return {std::string(url), "/"}; + return { + std::string(url.substr(0, pathStart)), + std::string(url.substr(pathStart))}; +} + +[[nodiscard]] std::string joinUrlPrefixAndPath( + std::string_view baseUrl, + std::string_view relativePath) +{ + std::string result(baseUrl); + if (!result.empty() && result.back() == '/' && + !relativePath.empty() && relativePath.front() == '/') { + result.pop_back(); + } + else if (!result.empty() && result.back() != '/' && + !relativePath.empty() && relativePath.front() != '/') { + result.push_back('/'); + } + result.append(relativePath); + return result; +} + +[[nodiscard]] bool hasGzipContentEncoding(std::string_view contentEncoding) +{ + if (contentEncoding.empty()) + return false; + + std::string normalized(contentEncoding); + std::ranges::transform( + normalized, + normalized.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + return normalized.find("gzip") != std::string::npos; +} + +[[nodiscard]] bool looksLikeGzip(std::string_view bytes) +{ + return bytes.size() >= 2 && + static_cast(bytes[0]) == 0x1f && + static_cast(bytes[1]) == 0x8b; +} + +[[nodiscard]] std::optional gunzip(std::string_view input) +{ + if (input.empty()) + return std::string{}; + if (input.size() > static_cast(std::numeric_limits::max())) + return std::nullopt; + + z_stream stream{}; + stream.next_in = reinterpret_cast(const_cast(input.data())); + stream.avail_in = static_cast(input.size()); + + if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) + return std::nullopt; + + std::string output; + output.reserve(input.size() * 2); + + char outBuffer[8192]; + int inflateResult = Z_OK; + do { + stream.next_out = reinterpret_cast(outBuffer); + stream.avail_out = sizeof(outBuffer); + inflateResult = inflate(&stream, Z_NO_FLUSH); + if (inflateResult != Z_OK && inflateResult != Z_STREAM_END) { + inflateEnd(&stream); + return std::nullopt; + } + output.append(outBuffer, sizeof(outBuffer) - stream.avail_out); + } while (inflateResult != Z_STREAM_END); + + inflateEnd(&stream); + return output; +} + +[[nodiscard]] std::optional decodeResponseBody( + const drogon::HttpResponsePtr& response) +{ + if (!response) + return std::nullopt; + + auto body = std::string_view(response->body().data(), response->body().size()); + auto contentEncoding = response->getHeader("Content-Encoding"); + if (contentEncoding.empty()) + contentEncoding = response->getHeader("content-encoding"); + + const auto headerSaysGzip = hasGzipContentEncoding(contentEncoding); + const auto bodyLooksGzip = looksLikeGzip(body); + if (headerSaysGzip && !bodyLooksGzip) + return std::string(body); + if (!headerSaysGzip && !bodyLooksGzip) + return std::string(body); + return gunzip(body); +} + +[[nodiscard]] std::string fetchHttpTextOnce(std::string const& url) +{ + auto parsedUrl = splitHttpUrl(url); + + trantor::EventLoopThread loopThread("GeoJsonSourceHttpFetch"); + loopThread.run(); + + auto client = drogon::HttpClient::newHttpClient(parsedUrl.origin, loopThread.getLoop()); + auto request = drogon::HttpRequest::newHttpRequest(); + request->setMethod(drogon::Get); + request->setPath(parsedUrl.pathAndQuery); + request->addHeader("Accept-Encoding", "gzip"); + + auto [result, response] = client->sendRequest(request); + if (result != drogon::ReqResult::Ok || !response) { + raise(fmt::format( + "Failed to fetch `{}`: [{}]", + url, + drogon::to_string_view(result))); + } + if (static_cast(response->statusCode()) >= 300) { + raise(fmt::format( + "Failed to fetch `{}`: HTTP {}", + url, + static_cast(response->statusCode()))); + } + + auto decodedBody = decodeResponseBody(response); + if (!decodedBody) + raise(fmt::format("Failed to decode response body from `{}`.", url)); + return *decodedBody; +} + +[[nodiscard]] std::optional loadConfiguredDataSourceInfo( + std::optional const& inlineJson, + std::string const& location, + std::function const& fetchText = {}) +{ + if (inlineJson && !location.empty()) { + raise("`dataSourceInfoJson` and `dataSourceInfoLocation` cannot be used together."); + } + + if (inlineJson) { + return DataSourceInfo::fromJson(*inlineJson); + } + + if (location.empty()) + return std::nullopt; + + nlohmann::json infoJson; + if (looksLikeHttpUrl(location)) { + auto body = fetchText ? fetchText(location) : fetchHttpTextOnce(location); + infoJson = parseStructuredDocument(body, location); } - if (j.is_array()) { - auto subArray = tfl->newArray(j.size(), true); - for (auto& el : j.items()) - subArray->append(jsonToMapget(tfl, el.value())); - return subArray; + else + infoJson = loadStructuredDocumentFile(location); + return DataSourceInfo::fromJson(infoJson); +} + +void ensureFeatureLayerInfoOnly(DataSourceInfo const& info, std::string_view context) +{ + for (auto const& [layerId, layerInfo] : info.layers_) { + if (layerInfo->type_ != LayerType::Features) { + raise(fmt::format( + "{} only supports feature layers, but `{}` is configured as type `{}`.", + context, + layerId, + nlohmann::json(layerInfo->type_).dump())); + } } +} - mapget::log().debug("Unhandled JSON type: {}", j.type_name()); - return {}; +void finalizeLoadedInfo(DataSourceInfo& info, std::string const& mapIdOverride) +{ + ensureFeatureLayerInfoOnly(info, "GeoJSON datasource"); + info.maxParallelJobs_ = std::max(info.maxParallelJobs_, 1); + if (info.nodeId_.empty()) + info.nodeId_ = generateNodeHexUuid(); + if (!mapIdOverride.empty()) + info.mapId_ = mapIdOverride; } -constexpr auto manifestFilename = "manifest.json"; +[[nodiscard]] DataSourceInfo synthesizeFallbackInfo(std::string const& mapId) +{ + auto fallbackLayerJson = nlohmann::json::parse(R"json( + { + "featureTypes": [ + { + "name": "AnyFeature", + "uniqueIdCompositions": [ + [ + {"partId": "tileId", "datatype": "U64"}, + {"partId": "featureIndex", "datatype": "U32"} + ] + ] + } + ] + })json"); + + auto layerInfo = LayerInfo::fromJson(fallbackLayerJson, "GeoJsonAny"); + DataSourceInfo info; + info.nodeId_ = generateNodeHexUuid(); + info.mapId_ = mapId; + info.maxParallelJobs_ = defaultParallelJobs(); + info.layers_.emplace(layerInfo->layerId_, std::move(layerInfo)); + return info; +} + +[[nodiscard]] std::string featureTypeNameForTile(const TileFeatureLayer::Ptr& tile) +{ + auto layerInfo = tile->layerInfo(); + if (!layerInfo->featureTypes_.empty()) + return layerInfo->featureTypes_.front().name_; + + if (layerInfo->layerId_ == "GeoJsonAny") + return "AnyFeature"; + return layerInfo->layerId_ + "Feature"; +} + +void fillGeoJsonTile( + const TileFeatureLayer::Ptr& tile, + std::string const& geoJsonBody, + bool withAttrLayers) +{ + tile->fromJson( + nlohmann::json::parse(geoJsonBody), + GeoJsonImportOptions{ + .strict_ = false, + .fallbackFeatureType_ = featureTypeNameForTile(tile), + .objectPropertiesAsAttributeLayers_ = withAttrLayers, + }); +} } // namespace @@ -57,14 +349,7 @@ namespace mapget::geojsonsource nlohmann::json GeoJsonSource::createLayerInfoJson(const std::string& layerName) { - // Create feature type name from layer name (e.g., "Road" -> "RoadFeature") - std::string featureTypeName = layerName; - if (layerName != "GeoJsonAny") { - featureTypeName = layerName + "Feature"; - } else { - featureTypeName = "AnyFeature"; - } - + std::string featureTypeName = layerName == "GeoJsonAny" ? "AnyFeature" : layerName + "Feature"; return nlohmann::json::parse(fmt::format(R"json( {{ "featureTypes": [ @@ -91,20 +376,14 @@ nlohmann::json GeoJsonSource::createLayerInfoJson(const std::string& layerName) bool GeoJsonSource::parseManifest() { - auto manifestPath = std::filesystem::path(inputDir_) / manifestFilename; - if (!std::filesystem::exists(manifestPath)) { + auto manifestPath = std::filesystem::path(inputDir_) / MANIFEST_FILENAME; + if (!std::filesystem::exists(manifestPath)) return false; - } try { - std::ifstream manifestFile(manifestPath); - nlohmann::json manifestJson; - manifestFile >> manifestJson; - - // Parse version (required) + auto manifestJson = loadStructuredDocumentFile(manifestPath.string()); manifest_.version = manifestJson.value("version", 1); - // Parse metadata (optional) if (manifestJson.contains("metadata")) { auto& meta = manifestJson["metadata"]; if (meta.contains("name")) @@ -121,39 +400,32 @@ bool GeoJsonSource::parseManifest() manifest_.metadata.license = meta["license"].get(); } - // Parse index (optional - if missing, will fall back to directory scan) if (manifestJson.contains("index")) { auto& index = manifestJson["index"]; - - // Default layer name manifest_.defaultLayer = index.value("defaultLayer", "GeoJsonAny"); - // Parse files if (index.contains("files")) { for (auto& [filename, fileInfo] : index["files"].items()) { FileEntry entry; entry.filename = filename; if (fileInfo.is_object()) { - // Full format: { "tileId": 123, "layer": "Road" } entry.tileId = fileInfo.value("tileId", uint64_t{0}); entry.layer = fileInfo.value("layer", std::string{}); - } else if (fileInfo.is_number()) { - // Short format: just the tile ID + } + else if (fileInfo.is_number()) { entry.tileId = fileInfo.get(); - } else { + } + else { mapget::log().warn( "Invalid file entry in manifest for '{}': expected object or number", filename); continue; } - // Use default layer if not specified - if (entry.layer.empty()) { + if (entry.layer.empty()) entry.layer = manifest_.defaultLayer; - } - // Validate file exists auto filePath = std::filesystem::path(inputDir_) / filename; if (!std::filesystem::exists(filePath)) { mapget::log().warn( @@ -170,10 +442,9 @@ bool GeoJsonSource::parseManifest() mapget::log().info( "Loaded manifest.json with {} file entries", manifest_.files.size()); - return true; - - } catch (const std::exception& e) { + } + catch (const std::exception& e) { mapget::log().error("Failed to parse manifest.json: {}", e.what()); return false; } @@ -181,105 +452,111 @@ bool GeoJsonSource::parseManifest() void GeoJsonSource::initFromManifest() { - // Build layer coverage and file mapping from manifest entries for (const auto& entry : manifest_.files) { - // Track coverage per layer layerCoverage_[entry.layer].insert(entry.tileId); - - // Map (tileId, layer) -> filename - TileLayerKey key{entry.tileId, entry.layer}; - tileLayerToFile_[key] = entry.filename; - - mapget::log().debug( - "Registered file '{}' -> tile {} in layer '{}'", - entry.filename, entry.tileId, entry.layer); + tileLayerToFile_[{entry.tileId, entry.layer}] = entry.filename; } - // Create LayerInfo for each discovered layer for (const auto& [layerName, tileIds] : layerCoverage_) { - auto layerJson = createLayerInfoJson(layerName); - auto layerInfo = mapget::LayerInfo::fromJson(layerJson, layerName); - - // Add coverage entries + auto layerInfo = mapget::LayerInfo::fromJson(createLayerInfoJson(layerName), layerName); for (uint64_t tileId : tileIds) { - mapget::Coverage coverage({tileId, tileId, std::vector()}); - layerInfo->coverage_.emplace_back(coverage); + layerInfo->coverage_.emplace_back(mapget::Coverage{tileId, tileId, {}}); } - - info_.layers_.emplace(layerName, layerInfo); - mapget::log().info( - "Layer '{}' initialized with {} tiles", - layerName, tileIds.size()); + info_.layers_.emplace(layerName, std::move(layerInfo)); } } void GeoJsonSource::initFromDirectory() { - // Legacy mode: scan for .geojson files const std::string defaultLayer = "GeoJsonAny"; - auto layerJson = createLayerInfoJson(defaultLayer); - auto layerInfo = mapget::LayerInfo::fromJson(layerJson, defaultLayer); + auto layerInfo = mapget::LayerInfo::fromJson(createLayerInfoJson(defaultLayer), defaultLayer); for (const auto& file : std::filesystem::directory_iterator(inputDir_)) { - mapget::log().debug("Found file {}", file.path().string()); - if (file.path().extension() == ".geojson") { - try { - auto tileId = static_cast(std::stoull(file.path().stem())); - layerCoverage_[defaultLayer].insert(tileId); - - TileLayerKey key{tileId, defaultLayer}; - tileLayerToFile_[key] = file.path().filename().string(); - - mapget::Coverage coverage({tileId, tileId, std::vector()}); - layerInfo->coverage_.emplace_back(coverage); - mapget::log().debug("Added tile {}", tileId); - } catch (const std::exception& e) { - mapget::log().debug( - "Skipping file '{}': filename is not a valid tile ID", - file.path().filename().string()); - } + if (file.path().extension() != ".geojson") + continue; + + try { + auto tileId = static_cast(std::stoull(file.path().stem())); + layerCoverage_[defaultLayer].insert(tileId); + tileLayerToFile_[{tileId, defaultLayer}] = file.path().filename().string(); + layerInfo->coverage_.emplace_back(mapget::Coverage{tileId, tileId, {}}); + } + catch (const std::exception&) { + mapget::log().debug( + "Skipping file '{}': filename is not a valid tile ID", + file.path().filename().string()); } } - info_.layers_.emplace(defaultLayer, layerInfo); + info_.layers_.emplace(defaultLayer, std::move(layerInfo)); +} + +GeoJsonSource::GeoJsonSource( + const std::string& inputDir, + bool withAttrLayers, + const std::string& mapId) + : GeoJsonSource( + inputDir, + GeoJsonSourceOptions{ + .withAttrLayers = withAttrLayers, + .mapId = mapId}) +{ } -GeoJsonSource::GeoJsonSource(const std::string& inputDir, bool withAttrLayers, const std::string& mapId) - : inputDir_(inputDir), withAttrLayers_(withAttrLayers) +GeoJsonSource::GeoJsonSource(std::string inputDir, GeoJsonSourceOptions options) + : inputDir_(std::move(inputDir)), + withAttrLayers_(options.withAttrLayers), + tilePathTemplate_(options.tilePathTemplate.empty() ? "{tileId}.geojson" : options.tilePathTemplate) { - // Compromise between performance and resource usage - info_.maxParallelJobs_ = std::max((int)(0.33*std::thread::hardware_concurrency()), 2); - info_.mapId_ = mapId.empty() ? mapNameFromUri(inputDir) : mapId; + info_.maxParallelJobs_ = defaultParallelJobs(); + info_.mapId_ = options.mapId.empty() ? mapNameFromUri(inputDir_) : options.mapId; info_.nodeId_ = generateNodeHexUuid(); - // Try to load manifest.json first - hasManifest_ = parseManifest(); + if (auto loadedInfo = loadConfiguredDataSourceInfo( + options.dataSourceInfoJson, + options.dataSourceInfoLocation)) { + info_ = std::move(*loadedInfo); + finalizeLoadedInfo(info_, options.mapId); + usesTemplatePaths_ = true; - if (hasManifest_) { - if (!manifest_.files.empty()) { - initFromManifest(); - } else { + if (std::filesystem::exists(std::filesystem::path(inputDir_) / MANIFEST_FILENAME)) { mapget::log().info( - "manifest.json found but has no index/files section - no tiles available"); + "GeoJsonFolder is using explicit dataSourceInfo; manifest.json in '{}' is ignored.", + inputDir_); } - } else { - mapget::log().warn( - "No manifest.json found in '{}'. " - "Using deprecated legacy mode with filename-based tile ID detection. " - "Legacy mode will be removed in a future release. " - "Please add a manifest.json for file mapping and multi-layer support.", - inputDir); - initFromDirectory(); } + else { + if (!options.tilePathTemplate.empty()) { + mapget::log().warn( + "GeoJsonFolder ignores `tilePathTemplate` without explicit dataSourceInfo. " + "Using manifest or legacy filename discovery instead."); + } - // Log summary - size_t totalTiles = 0; - for (const auto& [layer, tileIds] : layerCoverage_) { - totalTiles += tileIds.size(); + hasManifest_ = parseManifest(); + if (hasManifest_) { + if (!manifest_.files.empty()) { + initFromManifest(); + } + else { + mapget::log().info( + "manifest.json found but has no index/files section - no tiles available"); + } + } + else { + mapget::log().warn( + "No manifest.json found in '{}'. " + "Using deprecated legacy mode with filename-based tile ID detection. " + "Provide `dataSourceInfo` for template-based loading.", + inputDir_); + initFromDirectory(); + } } + mapget::log().info( - "GeoJsonSource initialized: {} layers, {} total tile entries", - info_.layers_.size(), totalTiles); + "GeoJsonFolder initialized: mapId='{}', layers={}, mode={}", + info_.mapId_, + info_.layers_.size(), + usesTemplatePaths_ ? "template" : hasManifest_ ? "manifest" : "legacy"); } mapget::DataSourceInfo GeoJsonSource::info() @@ -287,144 +564,225 @@ mapget::DataSourceInfo GeoJsonSource::info() return info_; } -void GeoJsonSource::fill(const mapget::TileFeatureLayer::Ptr& tile) +std::string GeoJsonSource::resolveTilePath(uint64_t tileId, std::string_view layerId) const { - using namespace mapget; - - auto tileId = tile->tileId().value_; - auto layerName = tile->layerInfo()->layerId_; + auto rendered = renderTemplate(tilePathTemplate_, tileId, layerId); + std::filesystem::path path(rendered); + if (path.is_absolute()) + return path.string(); + return (std::filesystem::path(inputDir_) / path).string(); +} - mapget::log().debug("Filling tile {} for layer '{}'", tileId, layerName); +std::string GeoJsonSource::readTileBody(uint64_t tileId, std::string_view layerId) const +{ + if (usesTemplatePaths_) { + auto path = resolveTilePath(tileId, layerId); + std::ifstream geojsonFile(path); + if (!geojsonFile) + raise(fmt::format("Failed to open GeoJSON file `{}`.", path)); + std::ostringstream buffer; + buffer << geojsonFile.rdbuf(); + return buffer.str(); + } - // Look up the file for this (tileId, layer) combination - TileLayerKey key{tileId, layerName}; + TileLayerKey key{tileId, std::string(layerId)}; auto fileIt = tileLayerToFile_.find(key); if (fileIt == tileLayerToFile_.end()) { - mapget::log().error( - "No file registered for tile {} in layer '{}'", - tileId, layerName); - return; + raise(fmt::format( + "No GeoJSON file registered for tile {} in layer `{}`.", + tileId, + layerId)); } - // All features share the same tile id - tile->setIdPrefix({{"tileId", static_cast(tileId)}}); - - // Build the full path auto path = (std::filesystem::path(inputDir_) / fileIt->second).string(); + std::ifstream geojsonFile(path); + if (!geojsonFile) + raise(fmt::format("Failed to open GeoJSON file `{}`.", path)); - mapget::log().debug("Opening: {}", path); + std::ostringstream buffer; + buffer << geojsonFile.rdbuf(); + return buffer.str(); +} - std::ifstream geojsonFile(path); - if (!geojsonFile) { - mapget::log().error("Failed to open file: {}", path); - return; +void GeoJsonSource::fill(const mapget::TileFeatureLayer::Ptr& tile) +{ + try { + fillGeoJsonTile( + tile, + readTileBody(tile->tileId().value_, tile->layerInfo()->layerId_), + withAttrLayers_); } + catch (const std::exception& e) { + tile->setError(e.what()); + mapget::log().error( + "GeoJsonFolder failed to fill tile {} for layer '{}': {}", + tile->tileId().value_, + tile->layerInfo()->layerId_, + e.what()); + } +} - nlohmann::json geojsonData; - geojsonFile >> geojsonData; - - mapget::log().debug("Processing {} features...", geojsonData["features"].size()); - - // Get the feature type name for this layer - std::string featureTypeName = (layerName == "GeoJsonAny") ? "AnyFeature" : (layerName + "Feature"); - - // Iterate over each feature in the GeoJSON data - int featureId = 0; - for (auto& feature_data : geojsonData["features"]) { - // Create a new feature - auto feature = tile->newFeature(featureTypeName, {{"featureIndex", featureId}}); - featureId++; - - // Parse geometry data (recursive lambda to support GeometryCollection) - std::function addGeometry; - addGeometry = [&](nlohmann::json const& geom) { - if (!geom.is_object() || !geom.contains("type")) - return; - auto const type = geom["type"].get(); - if (type == "Point") { - auto const& c = geom["coordinates"]; - feature->addPoint({c[0], c[1]}); - } - else if (type == "MultiPoint") { - auto points = feature->geom()->newGeometry(GeomType::Points, geom["coordinates"].size()); - for (auto const& c : geom["coordinates"]) - points->append({c[0], c[1]}); - } - else if (type == "LineString") { - auto line = feature->geom()->newGeometry(GeomType::Line, geom["coordinates"].size()); - for (auto const& c : geom["coordinates"]) - line->append({c[0], c[1]}); - } - else if (type == "MultiLineString") { - for (auto const& coords : geom["coordinates"]) { - auto line = feature->geom()->newGeometry(GeomType::Line, coords.size()); - for (auto const& c : coords) - line->append({c[0], c[1]}); - } - } - else if (type == "Polygon") { - if (!geom["coordinates"].empty()) { - auto const& ring = geom["coordinates"][0]; - auto poly = feature->geom()->newGeometry(GeomType::Polygon, ring.size()); - for (auto const& c : ring) - poly->append({c[0], c[1]}); - } - } - else if (type == "MultiPolygon") { - for (auto const& polygon : geom["coordinates"]) { - if (!polygon.empty()) { - auto const& ring = polygon[0]; - auto poly = feature->geom()->newGeometry(GeomType::Polygon, ring.size()); - for (auto const& c : ring) - poly->append({c[0], c[1]}); - } - } - } - else if (type == "GeometryCollection") { - for (auto const& child : geom["geometries"]) - addGeometry(child); - } - }; - addGeometry(feature_data["geometry"]); - - // Add top-level properties as attributes - for (auto& property : feature_data["properties"].items()) { +void GeoJsonSource::fill(mapget::TileSourceDataLayer::Ptr const&) +{ + // This datasource only serves feature tiles. +} - // Always emit scalar properties - if (!property.value().is_object()) { - feature->attributes()->addField(property.key(), jsonToMapget(tile, property.value())); - continue; - } +struct GeoJsonEndpointSource::Impl +{ + struct ClientPool + { + std::vector clients; + std::size_t nextClient = 0; + }; + + explicit Impl(int clientCount) + : clientCount_(std::max(clientCount, 1)) + { + loopThread_ = std::make_unique("GeoJsonEndpointSource"); + loopThread_->run(); + } - // Emit layers conditionally - if (!withAttrLayers_) - continue; - - // If the property value is an object, add an attribute layer - auto attrLayer = feature->attributeLayers()->newLayer(property.key()); - for (auto& attr : property.value().items()) { - auto attribute = attrLayer->newAttribute(attr.key()); - attribute->addField(attr.key(), jsonToMapget(tile, attr.value())); - - // Check if the value is an object and contains a "_direction" field - if (attr.value().is_object() && attr.value().contains("_direction")) { - std::string dir = attr.value()["_direction"]; - auto validDir = dir == "POSITIVE" ? Validity::Positive : - dir == "NEGATIVE" ? Validity::Negative : - (dir == "COMPLETE" || dir == "BOTH") ? Validity::Both : - Validity::Empty; - attribute->validity()->newDirection(validDir); - } + [[nodiscard]] drogon::HttpClientPtr acquireClient(std::string const& origin) + { + std::lock_guard lock(mutex_); + auto& pool = pools_[origin]; + if (pool.clients.empty()) { + pool.clients.reserve(clientCount_); + for (int i = 0; i < clientCount_; ++i) { + pool.clients.push_back(drogon::HttpClient::newHttpClient(origin, loopThread_->getLoop())); } } + auto client = pool.clients[pool.nextClient % pool.clients.size()]; + ++pool.nextClient; + return client; + } + + int clientCount_ = 1; + std::unique_ptr loopThread_; + std::mutex mutex_; + std::unordered_map pools_; +}; + +GeoJsonEndpointSource::GeoJsonEndpointSource(GeoJsonEndpointSourceOptions options) + : baseUrl_(trimmed(options.baseUrl)), + tileUrlTemplate_(options.tileUrlTemplate.empty() ? "{tileId}.geojson" : options.tileUrlTemplate), + withAttrLayers_(options.withAttrLayers), + fetchText_(std::move(options.fetchText)) +{ + if (baseUrl_.empty()) + raise("GeoJsonEndpoint requires a non-empty `baseUrl`."); + + info_.maxParallelJobs_ = defaultParallelJobs(); + info_.mapId_ = options.mapId.empty() ? mapNameFromUri(baseUrl_) : options.mapId; + info_.nodeId_ = generateNodeHexUuid(); + + if (auto loadedInfo = loadConfiguredDataSourceInfo( + options.dataSourceInfoJson, + options.dataSourceInfoLocation, + fetchText_)) { + info_ = std::move(*loadedInfo); + finalizeLoadedInfo(info_, options.mapId); + } + else { + info_ = synthesizeFallbackInfo(info_.mapId_); + if (!options.mapId.empty()) + info_.mapId_ = options.mapId; + + mapget::log().warn( + "No `dataSourceInfo` configured for GeoJsonEndpoint '{}'. " + "Only `GeoJsonAny` will be advertised, coverage stays empty, " + "and conversion will run in best-effort mode.", + baseUrl_); + } + + impl_ = std::make_unique(std::max(info_.maxParallelJobs_, 1)); + + mapget::log().info( + "GeoJsonEndpoint initialized: mapId='{}', layers={}, baseUrl='{}'", + info_.mapId_, + info_.layers_.size(), + baseUrl_); +} + +GeoJsonEndpointSource::~GeoJsonEndpointSource() = default; + +mapget::DataSourceInfo GeoJsonEndpointSource::info() +{ + return info_; +} + +std::string GeoJsonEndpointSource::renderTileUrl(uint64_t tileId, std::string_view layerId) const +{ + auto rendered = renderTemplate(tileUrlTemplate_, tileId, layerId, baseUrl_); + if (looksLikeHttpUrl(rendered)) + return rendered; + return joinUrlPrefixAndPath(baseUrl_, rendered); +} + +std::string GeoJsonEndpointSource::fetchTileBody(uint64_t tileId, std::string_view layerId) const +{ + auto url = renderTileUrl(tileId, layerId); + if (fetchText_) + return fetchText_(url); + + auto parsedUrl = splitHttpUrl(url); + auto client = impl_->acquireClient(parsedUrl.origin); + + auto request = drogon::HttpRequest::newHttpRequest(); + request->setMethod(drogon::Get); + request->setPath(parsedUrl.pathAndQuery); + request->addHeader("Accept-Encoding", "gzip"); + + auto [result, response] = client->sendRequest(request); + if (result != drogon::ReqResult::Ok || !response) { + raise(fmt::format( + "Failed to fetch tile `{}` for layer `{}` from `{}`: [{}]", + tileId, + layerId, + url, + drogon::to_string_view(result))); + } + if (static_cast(response->statusCode()) >= 300) { + raise(fmt::format( + "Failed to fetch tile `{}` for layer `{}` from `{}`: HTTP {}", + tileId, + layerId, + url, + static_cast(response->statusCode()))); } - mapget::log().debug(" done!"); + auto decodedBody = decodeResponseBody(response); + if (!decodedBody) { + raise(fmt::format( + "Failed to decode tile response for `{}` / `{}` from `{}`.", + tileId, + layerId, + url)); + } + return *decodedBody; } -void GeoJsonSource::fill(mapget::TileSourceDataLayer::Ptr const&) +void GeoJsonEndpointSource::fill(const mapget::TileFeatureLayer::Ptr& tile) +{ + try { + fillGeoJsonTile( + tile, + fetchTileBody(tile->tileId().value_, tile->layerInfo()->layerId_), + withAttrLayers_); + } + catch (const std::exception& e) { + tile->setError(e.what()); + mapget::log().error( + "GeoJsonEndpoint failed to fill tile {} for layer '{}': {}", + tile->tileId().value_, + tile->layerInfo()->layerId_, + e.what()); + } +} + +void GeoJsonEndpointSource::fill(mapget::TileSourceDataLayer::Ptr const&) { - // Do nothing... + // This datasource only serves feature tiles. } } // namespace mapget::geojsonsource diff --git a/libs/http-datasource/include/mapget/detail/http-server.h b/libs/http-datasource/include/mapget/detail/http-server.h index cc6db8cb..28871318 100644 --- a/libs/http-datasource/include/mapget/detail/http-server.h +++ b/libs/http-datasource/include/mapget/detail/http-server.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -90,4 +91,30 @@ class HttpServer std::unique_ptr impl_; }; +/** + * Ensure that a static filesystem mount is available to the running HTTP server. + * This is intended for embedding applications that discover additional static + * assets after the server has already started. The mount is process-global + * because Drogon exposes a process-global application instance. + * + * @param urlPrefix URL path prefix, e.g. `/assets`. + * @param filesystemRoot Directory to serve under the prefix. + * @return true when the mount exists or was added successfully. + */ +bool ensureStaticMount( + std::string const& urlPrefix, + std::filesystem::path const& filesystemRoot); + +/** + * Ensure a static mount using the same `[:]` syntax + * as HttpServer::mountFileSystem(). + */ +bool ensureStaticMount(std::string const& pathFromTo); + +/** + * Remove a runtime static mount previously added through ensureStaticMount(). + * Startup mounts added through HttpServer::mountFileSystem() are not affected. + */ +bool removeStaticMount(std::string const& urlPrefix); + } // namespace mapget diff --git a/libs/http-datasource/include/mapget/http-datasource/datasource-client.h b/libs/http-datasource/include/mapget/http-datasource/datasource-client.h index 5a00d000..aba55bf9 100644 --- a/libs/http-datasource/include/mapget/http-datasource/datasource-client.h +++ b/libs/http-datasource/include/mapget/http-datasource/datasource-client.h @@ -53,6 +53,9 @@ class RemoteDataSource : public DataSource DataSourceInfo const& info, TileLayer::LoadStateCallback loadStateCallback = {}) override; std::vector locate(const mapget::LocateRequest &req) override; + void onCacheExpired( + MapTileKey const& tileKey, + std::chrono::system_clock::time_point expiredAt) override; private: // DataSourceInfo is fetched in the constructor @@ -97,6 +100,9 @@ class RemoteDataSourceProcess : public DataSource DataSourceInfo const& info, TileLayer::LoadStateCallback loadStateCallback = {}) override; std::vector locate(const mapget::LocateRequest &req) override; + void onCacheExpired( + MapTileKey const& tileKey, + std::chrono::system_clock::time_point expiredAt) override; private: std::unique_ptr remoteSource_; diff --git a/libs/http-datasource/include/mapget/http-datasource/datasource-server.h b/libs/http-datasource/include/mapget/http-datasource/datasource-server.h index a0deda49..86c1a40f 100644 --- a/libs/http-datasource/include/mapget/http-datasource/datasource-server.h +++ b/libs/http-datasource/include/mapget/http-datasource/datasource-server.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "mapget/model/sourcedatalayer.h" #include "mapget/model/featurelayer.h" @@ -44,6 +45,13 @@ class DataSourceServer : public HttpServer DataSourceServer& onLocateRequest(std::function(LocateRequest const&)> const&); + /** + * Set the callback which will be invoked when a service reports that a + * cached tile for this remote datasource expired and is being refreshed. + */ + DataSourceServer& onCacheExpired( + std::function const&); + /** * Get the DataSourceInfo metadata which this instance was constructed with. */ diff --git a/libs/http-datasource/src/datasource-client.cpp b/libs/http-datasource/src/datasource-client.cpp index 9fc99f22..6665cc1c 100644 --- a/libs/http-datasource/src/datasource-client.cpp +++ b/libs/http-datasource/src/datasource-client.cpp @@ -158,6 +158,33 @@ std::vector RemoteDataSource::locate(const LocateRequest& req) return responseVector; } +void RemoteDataSource::onCacheExpired( + MapTileKey const& tileKey, + std::chrono::system_clock::time_point expiredAt) +{ + auto& client = httpClients_[(nextClient_++) % httpClients_.size()]; + + auto cacheExpiredReq = drogon::HttpRequest::newHttpRequest(); + cacheExpiredReq->setMethod(drogon::Post); + cacheExpiredReq->setPath("/cache-expired"); + cacheExpiredReq->setContentTypeCode(drogon::CT_APPLICATION_JSON); + cacheExpiredReq->setBody(nlohmann::json{ + {"tileKey", tileKey.toString()}, + {"expiredAt", std::chrono::duration_cast( + expiredAt.time_since_epoch()).count()}, + }.dump()); + + auto [resultCode, response] = client->sendRequest(cacheExpiredReq); + if (resultCode != drogon::ReqResult::Ok || !response || (int)response->statusCode() >= 300) { + log().warn( + "Failed to notify remote data source about cache expiry for {}: {}", + tileKey.toString(), + resultCode != drogon::ReqResult::Ok + ? drogon::to_string(resultCode) + : response ? fmt::format("HTTP {}", (int)response->statusCode()) : "No remote response"); + } +} + std::shared_ptr RemoteDataSource::fromHostPort(const std::string& hostPort) { auto delimiterPos = hostPort.find(':'); @@ -269,4 +296,13 @@ std::vector RemoteDataSourceProcess::locate(const LocateRequest& return remoteSource_->locate(req); } +void RemoteDataSourceProcess::onCacheExpired( + MapTileKey const& tileKey, + std::chrono::system_clock::time_point expiredAt) +{ + if (!remoteSource_) + raise("Remote data source is not initialized."); + remoteSource_->onCacheExpired(tileKey, expiredAt); +} + } diff --git a/libs/http-datasource/src/datasource-server.cpp b/libs/http-datasource/src/datasource-server.cpp index 1d93813c..38940840 100644 --- a/libs/http-datasource/src/datasource-server.cpp +++ b/libs/http-datasource/src/datasource-server.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -26,6 +27,7 @@ struct DataSourceServer::Impl throw std::runtime_error("TileSourceDataLayer callback is unset!"); }; std::function(const LocateRequest&)> locateCallback_; + std::function cacheExpiredCallback_; std::shared_ptr strings_; explicit Impl(DataSourceInfo info) : info_(std::move(info)), strings_(std::make_shared(info_.nodeId_)) @@ -59,6 +61,13 @@ DataSourceServer& DataSourceServer::onLocateRequest( return *this; } +DataSourceServer& DataSourceServer::onCacheExpired( + const std::function& callback) +{ + impl_->cacheExpiredCallback_ = callback; + return *this; +} + DataSourceInfo const& DataSourceServer::info() { return impl_->info_; } void DataSourceServer::setup(drogon::HttpAppFramework& app) @@ -194,6 +203,34 @@ void DataSourceServer::setup(drogon::HttpAppFramework& app) } }, {drogon::Post}); + + app.registerHandler( + "/cache-expired", + [this](const drogon::HttpRequestPtr& req, std::function&& callback) + { + try { + if (impl_->cacheExpiredCallback_) { + auto const body = nlohmann::json::parse(std::string(req->body())); + auto const tileKey = MapTileKey(body.at("tileKey").get()); + auto const expiredAtUs = body.at("expiredAt").get(); + auto const expiredAt = std::chrono::system_clock::time_point{ + std::chrono::microseconds{expiredAtUs}}; + impl_->cacheExpiredCallback_(tileKey, expiredAt); + } + + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k204NoContent); + callback(resp); + } + catch (std::exception const& e) { + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k400BadRequest); + resp->setContentTypeCode(drogon::CT_TEXT_PLAIN); + resp->setBody(std::string("Invalid request: ") + e.what()); + callback(resp); + } + }, + {drogon::Post}); } } // namespace mapget diff --git a/libs/http-datasource/src/http-server.cpp b/libs/http-datasource/src/http-server.cpp index e39cb847..73d35884 100644 --- a/libs/http-datasource/src/http-server.cpp +++ b/libs/http-datasource/src/http-server.cpp @@ -2,6 +2,8 @@ #include "mapget/log.h" #include +#include +#include #include #include @@ -12,9 +14,11 @@ #include #include #include +#include #include #include #include +#include #include #include "fmt/format.h" @@ -38,6 +42,12 @@ struct MountPoint std::filesystem::path fsRoot; }; +struct RuntimeStaticMountRegistry +{ + std::mutex mutex; + std::vector mounts; +}; + [[nodiscard]] bool looksLikeWindowsDrivePath(std::string_view s) { if (s.size() < 3) @@ -57,6 +67,133 @@ struct MountPoint return prefix; } +[[nodiscard]] RuntimeStaticMountRegistry& runtimeStaticMountRegistry() +{ + static RuntimeStaticMountRegistry registry; + return registry; +} + +[[nodiscard]] std::optional normalizeMountPoint( + std::string urlPrefix, + std::filesystem::path fsRoot) +{ + urlPrefix = normalizeUrlPrefix(std::move(urlPrefix)); + + std::error_code ec; + fsRoot = std::filesystem::absolute(fsRoot, ec); + if (ec) + return std::nullopt; + fsRoot = std::filesystem::weakly_canonical(fsRoot, ec); + if (ec) + return std::nullopt; + + auto exists = std::filesystem::exists(fsRoot, ec); + if (!exists || ec) + return std::nullopt; + auto isDirectory = std::filesystem::is_directory(fsRoot, ec); + if (!isDirectory || ec) + return std::nullopt; + + return MountPoint{std::move(urlPrefix), std::move(fsRoot)}; +} + +[[nodiscard]] std::optional parseMountPoint(std::string const& pathFromTo) +{ + std::string urlPrefix; + std::string fsRootStr; + + const auto firstColon = pathFromTo.find(':'); + if (firstColon == std::string::npos || looksLikeWindowsDrivePath(pathFromTo)) { + urlPrefix = "/"; + fsRootStr = pathFromTo; + } else { + urlPrefix = pathFromTo.substr(0, firstColon); + fsRootStr = pathFromTo.substr(firstColon + 1); + if (fsRootStr.empty()) + return std::nullopt; + } + + return normalizeMountPoint(std::move(urlPrefix), std::filesystem::path(fsRootStr)); +} + +[[nodiscard]] bool pathIsWithin(std::filesystem::path const& path, std::filesystem::path const& root) +{ + auto pathIt = path.begin(); + for (auto rootIt = root.begin(); rootIt != root.end(); ++rootIt, ++pathIt) { + if (pathIt == path.end() || *pathIt != *rootIt) { + return false; + } + } + return true; +} + +[[nodiscard]] bool requestPathMatchesPrefix(std::string_view requestPath, std::string_view urlPrefix) +{ + if (requestPath == urlPrefix) + return true; + return requestPath.size() > urlPrefix.size() + && requestPath.substr(0, urlPrefix.size()) == urlPrefix + && requestPath[urlPrefix.size()] == '/'; +} + +[[nodiscard]] std::optional resolveMountedRequestPath( + MountPoint const& mount, + std::string const& requestPath) +{ + if (!requestPathMatchesPrefix(requestPath, mount.urlPrefix)) + return std::nullopt; + + auto relativePath = requestPath == mount.urlPrefix + ? std::string() + : requestPath.substr(mount.urlPrefix.size() + 1); + if (relativePath.empty()) + return std::nullopt; + + std::error_code ec; + auto candidate = std::filesystem::weakly_canonical(mount.fsRoot / std::filesystem::path(relativePath), ec); + if (ec || !pathIsWithin(candidate, mount.fsRoot)) + return std::nullopt; + + auto exists = std::filesystem::exists(candidate, ec); + if (!exists || ec) + return std::nullopt; + auto isRegularFile = std::filesystem::is_regular_file(candidate, ec); + if (!isRegularFile || ec) + return std::nullopt; + + return candidate; +} + +[[nodiscard]] std::optional dynamicStaticMountResponse( + drogon::HttpRequestPtr const& req) +{ + if (req->method() != drogon::Get && req->method() != drogon::Head) { + return std::nullopt; + } + + std::vector mounts; + { + auto& registry = runtimeStaticMountRegistry(); + std::lock_guard lock(registry.mutex); + mounts = registry.mounts; + } + if (mounts.empty()) + return std::nullopt; + + std::sort( + mounts.begin(), + mounts.end(), + [](MountPoint const& a, MountPoint const& b) { return a.urlPrefix.size() > b.urlPrefix.size(); }); + + for (auto const& mount : mounts) { + if (auto localPath = resolveMountedRequestPath(mount, req->path())) { + return drogon::HttpResponse::newFileResponse(localPath->string(), "", drogon::CT_NONE, "", req); + } + } + + return std::nullopt; +} + } // namespace struct HttpServer::Impl @@ -150,6 +287,17 @@ void HttpServer::go(std::string const& interfaceAddr, uint16_t port, uint32_t wa } } + app.registerPreRoutingAdvice( + [](drogon::HttpRequestPtr const& req, + drogon::AdviceCallback&& callback, + drogon::AdviceChainCallback&& chainCallback) { + if (auto response = dynamicStaticMountResponse(req)) { + callback(*response); + return; + } + chainCallback(); + }); + // Allow derived class to set up the server. setup(app); @@ -245,43 +393,77 @@ void HttpServer::waitForSignal() bool HttpServer::mountFileSystem(std::string const& pathFromTo) { - std::string urlPrefix; - std::string fsRootStr; + auto mount = parseMountPoint(pathFromTo); + if (!mount) + return false; - const auto firstColon = pathFromTo.find(':'); - if (firstColon == std::string::npos || looksLikeWindowsDrivePath(pathFromTo)) { - urlPrefix = "/"; - fsRootStr = pathFromTo; - } else { - urlPrefix = pathFromTo.substr(0, firstColon); - fsRootStr = pathFromTo.substr(firstColon + 1); - if (fsRootStr.empty()) - return false; - } + std::scoped_lock lock(impl_->mountsMutex_); + impl_->mounts_.emplace_back(std::move(*mount)); + return true; +} - urlPrefix = normalizeUrlPrefix(std::move(urlPrefix)); +void HttpServer::printPortToStdOut(bool enabled) +{ + impl_->printPortToStdout_ = enabled; +} - std::filesystem::path fsRoot(fsRootStr); - std::error_code ec; - fsRoot = std::filesystem::absolute(fsRoot, ec); - if (ec) +bool ensureStaticMount(std::string const& urlPrefix, std::filesystem::path const& filesystemRoot) +{ + auto mount = normalizeMountPoint(urlPrefix, filesystemRoot); + if (!mount) return false; - auto exists = std::filesystem::exists(fsRoot, ec); - if (!exists || ec) - return false; - auto isDirectory = std::filesystem::is_directory(fsRoot, ec); - if (!isDirectory || ec) + auto& registry = runtimeStaticMountRegistry(); + std::lock_guard lock(registry.mutex); + for (auto const& existing : registry.mounts) { + if (existing.urlPrefix != mount->urlPrefix) + continue; + if (existing.fsRoot == mount->fsRoot) + return true; + log().warn( + "Refusing to remount static URL prefix {} from {} to {}.", + mount->urlPrefix, + existing.fsRoot.generic_string(), + mount->fsRoot.generic_string()); return false; + } - std::scoped_lock lock(impl_->mountsMutex_); - impl_->mounts_.emplace_back(MountPoint{std::move(urlPrefix), std::move(fsRoot)}); + log().info( + "Static mount: {}:{}", + mount->urlPrefix, + mount->fsRoot.generic_string()); + registry.mounts.emplace_back(std::move(*mount)); return true; } -void HttpServer::printPortToStdOut(bool enabled) +bool ensureStaticMount(std::string const& pathFromTo) { - impl_->printPortToStdout_ = enabled; + auto mount = parseMountPoint(pathFromTo); + if (!mount) + return false; + return ensureStaticMount(mount->urlPrefix, mount->fsRoot); +} + +bool removeStaticMount(std::string const& urlPrefix) +{ + auto normalizedUrlPrefix = normalizeUrlPrefix(urlPrefix); + auto& registry = runtimeStaticMountRegistry(); + std::lock_guard lock(registry.mutex); + auto previousSize = registry.mounts.size(); + registry.mounts.erase( + std::remove_if( + registry.mounts.begin(), + registry.mounts.end(), + [&normalizedUrlPrefix](MountPoint const& mount) { + return mount.urlPrefix == normalizedUrlPrefix; + }), + registry.mounts.end()); + + if (registry.mounts.size() == previousSize) + return false; + + log().info("Removed static mount: {}", normalizedUrlPrefix); + return true; } } // namespace mapget diff --git a/libs/http-service/src/cli.cpp b/libs/http-service/src/cli.cpp index 90e92e76..2f909d8f 100644 --- a/libs/http-service/src/cli.cpp +++ b/libs/http-service/src/cli.cpp @@ -20,6 +20,9 @@ #include #include #include +#include +#include +#include #ifdef _WIN32 #include @@ -72,7 +75,6 @@ nlohmann::json gridDataSourceSchema() return { {"type", "object"}, {"properties", { - {"enabled", {{"type", "boolean"}, {"title", "Enabled"}}}, {"mapId", {{"type", "string"}, {"title", "Map ID"}}}, {"spatialCoherence", {{"type", "boolean"}}}, {"collisionGridSize", {{"type", "number"}}}, @@ -102,6 +104,19 @@ nlohmann::json geoJsonFolderSchema() {"title", "With Attribute Layers"}, {"description", "Convert nested GeoJSON property objects to mapget attribute layers. Default: true."}, {"default", true} + }}, + {"tilePathTemplate", { + {"type", "string"}, + {"title", "Tile Path Template"}, + {"description", "Relative path template used with explicit dataSourceInfo, e.g. `{layerId}/{tileId}.geojson`."} + }}, + {"dataSourceInfo", { + {"title", "Datasource Info"}, + {"description", "Inline datasource info object, local YAML/JSON file path, or HTTP(S) URL."}, + {"oneOf", nlohmann::json::array({ + nlohmann::json{{"type", "string"}}, + nlohmann::json{{"type", "object"}} + })} }} }}, {"required", nlohmann::json::array({"folder"})}, @@ -109,6 +124,152 @@ nlohmann::json geoJsonFolderSchema() }; } +nlohmann::json geoJsonEndpointSchema() +{ + return { + {"type", "object"}, + {"properties", { + {"baseUrl", { + {"type", "string"}, + {"title", "Base URL"}, + {"description", "Base HTTP(S) URL used to fetch GeoJSON tiles."} + }}, + {"mapId", { + {"type", "string"}, + {"title", "Map ID"}, + {"description", "Custom map identifier. If not provided, derived from the baseUrl."} + }}, + {"withAttrLayers", { + {"type", "boolean"}, + {"title", "With Attribute Layers"}, + {"description", "Convert nested GeoJSON property objects to mapget attribute layers. Default: true."}, + {"default", true} + }}, + {"tileUrlTemplate", { + {"type", "string"}, + {"title", "Tile URL Template"}, + {"description", "URL or relative path template used to fetch tiles, e.g. `{layerId}/{tileId}.geojson`."} + }}, + {"dataSourceInfo", { + {"title", "Datasource Info"}, + {"description", "Inline datasource info object, local YAML/JSON file path, or HTTP(S) URL."}, + {"oneOf", nlohmann::json::array({ + nlohmann::json{{"type", "string"}}, + nlohmann::json{{"type", "object"}} + })} + }} + }}, + {"required", nlohmann::json::array({"baseUrl"})}, + {"additionalProperties", false} + }; +} + +[[nodiscard]] bool looksLikeHttpUrl(std::string_view value) +{ + static constexpr char cleartextScheme[] = {'h', 't', 't', 'p', '\0'}; + static constexpr char tlsScheme[] = {'h', 't', 't', 'p', 's', '\0'}; + constexpr std::string_view delimiter = "://"; + auto hasScheme = [&](std::string_view scheme) { + return value.size() > scheme.size() + delimiter.size() && + value.compare(0, scheme.size(), scheme) == 0 && + value.compare(scheme.size(), delimiter.size(), delimiter) == 0; + }; + return hasScheme({cleartextScheme, 4}) || hasScheme({tlsScheme, 5}); +} + +[[nodiscard]] std::string trimCopy(std::string value) +{ + auto isWhitespace = [](unsigned char ch) { return std::isspace(ch) != 0; }; + value.erase(value.begin(), std::find_if(value.begin(), value.end(), [&](char ch) { + return !isWhitespace(static_cast(ch)); + })); + value.erase(std::find_if(value.rbegin(), value.rend(), [&](char ch) { + return !isWhitespace(static_cast(ch)); + }).base(), value.end()); + return value; +} + +[[nodiscard]] std::filesystem::path configDirectory() +{ + if (auto configPath = DataSourceConfigService::get().getConfigFilePath()) { + return std::filesystem::path(*configPath).parent_path(); + } + return std::filesystem::current_path(); +} + +void applyStructuredDocumentOption( + const YAML::Node& config, + std::optional& inlineJsonOut, + std::string& locationOut) +{ + if (!config) { + return; + } + + if (config.IsMap() || config.IsSequence()) { + inlineJsonOut = yamlToJson(config, false); + return; + } + + if (!config.IsScalar()) { + throw std::runtime_error("`dataSourceInfo` must be a mapping or scalar string."); + } + + auto value = trimCopy(config.as()); + if (value.empty()) { + return; + } + + if (value.front() == '{' || value.front() == '[') { + inlineJsonOut = parseStructuredDocument(value, "inline dataSourceInfo"); + return; + } + + if (looksLikeHttpUrl(value)) { + locationOut = value; + return; + } + + auto path = std::filesystem::path(value); + if (path.is_relative()) + path = configDirectory() / path; + locationOut = path.lexically_normal().string(); +} + +[[nodiscard]] geojsonsource::GeoJsonSourceOptions makeGeoJsonFolderOptions(YAML::Node const& config) +{ + geojsonsource::GeoJsonSourceOptions options; + if (auto withAttributeLayersNode = config["withAttrLayers"]) + options.withAttrLayers = withAttributeLayersNode.as(); + if (auto mapIdNode = config["mapId"]) + options.mapId = mapIdNode.as(); + if (auto tilePathTemplateNode = config["tilePathTemplate"]) + options.tilePathTemplate = tilePathTemplateNode.as(); + applyStructuredDocumentOption( + config["dataSourceInfo"], + options.dataSourceInfoJson, + options.dataSourceInfoLocation); + return options; +} + +[[nodiscard]] geojsonsource::GeoJsonEndpointSourceOptions makeGeoJsonEndpointOptions(YAML::Node const& config) +{ + geojsonsource::GeoJsonEndpointSourceOptions options; + if (auto baseUrlNode = config["baseUrl"]) + options.baseUrl = baseUrlNode.as(); + if (auto withAttributeLayersNode = config["withAttrLayers"]) + options.withAttrLayers = withAttributeLayersNode.as(); + if (auto mapIdNode = config["mapId"]) + options.mapId = mapIdNode.as(); + if (auto tileUrlTemplateNode = config["tileUrlTemplate"]) + options.tileUrlTemplate = tileUrlTemplateNode.as(); + applyStructuredDocumentOption( + config["dataSourceInfo"], + options.dataSourceInfoJson, + options.dataSourceInfoLocation); + return options; +} + class ConfigYAML : public CLI::Config { public: @@ -228,29 +389,28 @@ void registerDefaultDatasourceTypes() { dataSourceProcessSchema()); service.registerDataSourceType( "GridDataSource", - [](YAML::Node const& config) -> DataSource::Ptr { - // Check if enabled flag is present and set to false - if (config["enabled"].IsDefined() && !config["enabled"].as()) { - return nullptr; // Skip this datasource - } - return std::make_shared(config); - }, + [](YAML::Node const& config) -> DataSource::Ptr { return std::make_shared(config); }, gridDataSourceSchema()); service.registerDataSourceType( "GeoJsonFolder", [](YAML::Node const& config) -> DataSource::Ptr { if (auto folder = config["folder"]) { - bool withAttributeLayers = true; - if (auto withAttributeLayersNode = config["withAttrLayers"]) - withAttributeLayers = withAttributeLayersNode.as(); - std::string mapId; - if (auto mapIdNode = config["mapId"]) - mapId = mapIdNode.as(); - return std::make_shared(folder.as(), withAttributeLayers, mapId); + return std::make_shared( + folder.as(), + makeGeoJsonFolderOptions(config)); } throw std::runtime_error("Missing `folder` field."); }, geoJsonFolderSchema()); + service.registerDataSourceType( + "GeoJsonEndpoint", + [](YAML::Node const& config) -> DataSource::Ptr { + auto options = makeGeoJsonEndpointOptions(config); + if (options.baseUrl.empty()) + throw std::runtime_error("Missing `baseUrl` field."); + return std::make_shared(std::move(options)); + }, + geoJsonEndpointSchema()); } void loadConfigSchemaPatch(const std::string& schemaPath) @@ -282,7 +442,10 @@ struct ServeCommand std::string cachePath_; int64_t cacheMaxTiles_ = 1024; bool clearCache_ = false; + bool allowPostConfigEndpoint_ = false; + bool noGetConfigEndpoint_ = false; std::string webapp_; + std::vector staticMounts_; int64_t ttlSeconds_ = 0; uint64_t memoryTrimIntervalBinary_ = HttpServiceConfig{}.memoryTrimIntervalBinary; // Use default from config uint64_t memoryTrimIntervalJson_ = HttpServiceConfig{}.memoryTrimIntervalJson; // Use default from config @@ -332,14 +495,18 @@ struct ServeCommand "-w,--webapp", webapp_, "Serve a static web application, in the format [:]."); + serveCmd->add_option( + "--static-mount", + staticMounts_, + "Serve an additional static filesystem mount, in the format [:]. Can be specified multiple times."); serveCmd->add_flag( "--allow-post-config", - isPostConfigEndpointEnabled_, + allowPostConfigEndpoint_, "Allow the POST /config endpoint."); serveCmd->add_flag( "--no-get-config", - isGetConfigEndpointEnabled_, - "Allow the GET /config endpoint."); + noGetConfigEndpoint_, + "Disable the GET /config datasource model endpoint."); serveCmd->add_option( "--memory-trim-binary-interval", memoryTrimIntervalBinary_, @@ -362,6 +529,8 @@ struct ServeCommand if (ttlSeconds_ < 0) { raise("TTL must not be negative."); } + setPostConfigEndpointEnabled(allowPostConfigEndpoint_); + setGetConfigEndpointEnabled(!noGetConfigEndpoint_); log().info("Starting server on port {}.", port_); std::shared_ptr cache; @@ -460,6 +629,16 @@ struct ServeCommand } } + if (!staticMounts_.empty()) { + for (auto const& staticMount : staticMounts_) { + log().info("Static mount: {}", staticMount); + if (!srv.mountFileSystem(staticMount)) { + log().error(" ...failed to mount!"); + raise("Failed to mount static filesystem path."); + } + } + } + srv.go("0.0.0.0", port_); srv.waitForSignal(); } diff --git a/libs/http-service/src/config-handler.cpp b/libs/http-service/src/config-handler.cpp index 82ac86e6..c1f8e2f0 100644 --- a/libs/http-service/src/config-handler.cpp +++ b/libs/http-service/src/config-handler.cpp @@ -7,17 +7,157 @@ #include #include +#include #include #include #include +#include +#include +#include +#include #include #include +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + #include "nlohmann/json.hpp" #include "yaml-cpp/yaml.h" namespace mapget { +namespace +{ + +constexpr std::string_view kUnavailableReasonGetConfigDisabled = "getConfigDisabled"; +constexpr std::string_view kUnavailableReasonConfigPathUnset = "configPathUnset"; +constexpr std::string_view kUnavailableReasonConfigFileMissing = "configFileMissing"; +constexpr std::string_view kUnavailableReasonConfigFileOpenFailed = "configFileOpenFailed"; +constexpr std::string_view kUnavailableReasonConfigParseFailed = "configParseFailed"; +constexpr std::string_view kUnavailableReasonConfigValidationFailed = "configValidationFailed"; + +[[nodiscard]] YAML::Node loadConfigYamlForPublicSections() +{ + auto configFilePath = DataSourceConfigService::get().getConfigFilePath(); + if (!configFilePath.has_value()) { + return {}; + } + + std::filesystem::path path = *configFilePath; + if (!std::filesystem::exists(path)) { + return {}; + } + + std::ifstream configFile(*configFilePath); + if (!configFile) { + return {}; + } + + try { + return YAML::Load(configFile); + } + catch (const YAML::Exception& yamlError) { + log().warn("Failed to parse YAML config for public /config sections: {}", yamlError.what()); + } + return {}; +} + +[[nodiscard]] nlohmann::json buildUnavailableConfigResponse( + std::string_view reason, + YAML::Node const& fullConfig = {}, + bool readOnly = true, + bool includeSchema = false) +{ + auto& configService = DataSourceConfigService::get(); + nlohmann::json response = { + {"schema", includeSchema ? configService.getDataSourceConfigSchema() : nlohmann::json::object()}, + {"model", nlohmann::json::object()}, + {"readOnly", readOnly}, + {"datasourceConfigUnavailable", true}, + {"datasourceConfigUnavailableReason", reason}, + }; + auto publicSections = configService.getPublicConfigSections(fullConfig); + for (auto& [name, value] : publicSections.items()) { + response[name] = std::move(value); + } + return response; +} + +[[nodiscard]] drogon::HttpResponsePtr jsonResponse(nlohmann::json payload) +{ + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k200OK); + resp->setContentTypeCode(drogon::CT_APPLICATION_JSON); + resp->setBody(payload.dump(2)); + return resp; +} + +/** + * Rewrite the config via a temporary file so filesystem watchers never observe + * an empty or partially written target file. + */ +[[nodiscard]] std::optional replaceConfigFile( + std::filesystem::path const& configFilePath, + YAML::Node const& yamlConfig) +{ + std::ostringstream serializedConfig; + serializedConfig << yamlConfig; + + static std::atomic_uint64_t tempFileCounter{0}; + auto tempConfigPath = configFilePath; + auto tempFileSuffix = std::string(".tmp.") + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + "." + + std::to_string(tempFileCounter.fetch_add(1, std::memory_order_relaxed)); + tempConfigPath += tempFileSuffix; + + { + std::ofstream tempConfigFile(tempConfigPath, std::ios::out | std::ios::trunc); + if (!tempConfigFile) { + return std::string("failed to open temporary config file ") + tempConfigPath.string(); + } + + tempConfigFile << serializedConfig.str(); + tempConfigFile.flush(); + if (!tempConfigFile) { + std::error_code cleanupError; + std::filesystem::remove(tempConfigPath, cleanupError); + return std::string("failed to write temporary config file ") + tempConfigPath.string(); + } + } + +#ifdef _WIN32 + // Windows std::filesystem::rename does not portably replace existing files. + if (!MoveFileExW( + tempConfigPath.wstring().c_str(), + configFilePath.wstring().c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + auto const errorCode = GetLastError(); + std::error_code cleanupError; + std::filesystem::remove(tempConfigPath, cleanupError); + return std::string("failed to replace config file ") + configFilePath.string() + + " with temporary config file " + tempConfigPath.string() + ": Windows error " + + std::to_string(errorCode); + } +#else + std::error_code replaceError; + std::filesystem::rename(tempConfigPath, configFilePath, replaceError); + if (replaceError) { + std::error_code cleanupError; + std::filesystem::remove(tempConfigPath, cleanupError); + return std::string("failed to replace config file ") + configFilePath.string() + + " with temporary config file " + tempConfigPath.string() + ": " + + replaceError.message(); + } +#endif + + return std::nullopt; +} + +} // namespace drogon::HttpResponsePtr HttpService::Impl::openConfigFile(std::ifstream& configFile) { @@ -55,49 +195,72 @@ void HttpService::Impl::handleGetConfigRequest( const drogon::HttpRequestPtr& /*req*/, std::function&& callback) { + auto& configService = DataSourceConfigService::get(); + if (!isGetConfigEndpointEnabled()) { - auto resp = drogon::HttpResponse::newHttpResponse(); - resp->setStatusCode(drogon::k403Forbidden); - resp->setContentTypeCode(drogon::CT_TEXT_PLAIN); - resp->setBody("The GET /config endpoint is disabled by the server administrator."); - callback(resp); + const bool readOnly = !isPostConfigEndpointEnabled(); + callback(jsonResponse(buildUnavailableConfigResponse( + kUnavailableReasonGetConfigDisabled, + loadConfigYamlForPublicSections(), + readOnly, + !readOnly))); return; } - std::ifstream configFile; - if (auto errorResp = openConfigFile(configFile)) { - callback(errorResp); + auto configFilePath = configService.getConfigFilePath(); + if (!configFilePath.has_value()) { + callback(jsonResponse(buildUnavailableConfigResponse(kUnavailableReasonConfigPathUnset))); return; } - nlohmann::json jsonSchema = DataSourceConfigService::get().getDataSourceConfigSchema(); + std::filesystem::path path = *configFilePath; + if (!std::filesystem::exists(path)) { + callback(jsonResponse(buildUnavailableConfigResponse(kUnavailableReasonConfigFileMissing))); + return; + } + + std::ifstream configFile(*configFilePath); + if (!configFile) { + callback(jsonResponse(buildUnavailableConfigResponse(kUnavailableReasonConfigFileOpenFailed))); + return; + } try { YAML::Node configYaml = YAML::Load(configFile); + configService.validateDataSourceConfig(configYaml); + nlohmann::json jsonConfig; std::unordered_map maskedSecretMap; - for (const auto& key : DataSourceConfigService::get().topLevelDataSourceConfigKeys()) { + for (const auto& key : configService.topLevelDataSourceConfigKeys()) { if (auto configYamlEntry = configYaml[key]) jsonConfig[key] = yamlToJson(configYaml[key], true, &maskedSecretMap); } - nlohmann::json combinedJson; - combinedJson["schema"] = jsonSchema; - combinedJson["model"] = jsonConfig; - combinedJson["readOnly"] = !isPostConfigEndpointEnabled(); + nlohmann::json combinedJson = { + {"schema", configService.getDataSourceConfigSchema()}, + {"model", std::move(jsonConfig)}, + {"readOnly", !isPostConfigEndpointEnabled()}, + {"datasourceConfigUnavailable", false}, + {"datasourceConfigUnavailableReason", nullptr}, + }; + auto publicSections = configService.getPublicConfigSections(configYaml); + for (auto& [name, value] : publicSections.items()) { + combinedJson[name] = std::move(value); + } - auto resp = drogon::HttpResponse::newHttpResponse(); - resp->setStatusCode(drogon::k200OK); - resp->setContentTypeCode(drogon::CT_APPLICATION_JSON); - resp->setBody(combinedJson.dump(2)); - callback(resp); + callback(jsonResponse(std::move(combinedJson))); + } + catch (const std::invalid_argument& validationError) { + log().warn("GET /config validation failed: {}", validationError.what()); + callback(jsonResponse(buildUnavailableConfigResponse(kUnavailableReasonConfigValidationFailed))); + } + catch (const YAML::Exception& yamlError) { + log().warn("GET /config parse failed: {}", yamlError.what()); + callback(jsonResponse(buildUnavailableConfigResponse(kUnavailableReasonConfigParseFailed))); } catch (const std::exception& e) { - auto resp = drogon::HttpResponse::newHttpResponse(); - resp->setStatusCode(drogon::k500InternalServerError); - resp->setContentTypeCode(drogon::CT_TEXT_PLAIN); - resp->setBody(std::string("Error processing config file: ") + e.what()); - callback(resp); + log().warn("GET /config failed: {}", e.what()); + callback(jsonResponse(buildUnavailableConfigResponse(kUnavailableReasonConfigParseFailed))); } } @@ -201,13 +364,33 @@ void HttpService::Impl::handlePostConfigRequest( configFile.close(); log().trace("Writing new config."); - state->wroteConfig = true; - if (auto configFilePath = DataSourceConfigService::get().getConfigFilePath()) { - std::ofstream newConfigFile(*configFilePath); - newConfigFile << yamlConfig; - newConfigFile.close(); + auto configFilePath = DataSourceConfigService::get().getConfigFilePath(); + if (!configFilePath) { + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k500InternalServerError); + resp->setContentTypeCode(drogon::CT_TEXT_PLAIN); + resp->setBody("Error applying the configuration: config file path is no longer set."); + state->done = true; + state->callback(resp); + state->subscription.reset(); + return; + } + + if (auto writeError = replaceConfigFile(*configFilePath, yamlConfig)) { + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k500InternalServerError); + resp->setContentTypeCode(drogon::CT_TEXT_PLAIN); + resp->setBody(std::string("Error applying the configuration: ") + *writeError); + state->done = true; + state->callback(resp); + state->subscription.reset(); + return; } + // Ignore watcher callbacks until the rewritten file has been fully replaced. + state->wroteConfig = true; + DataSourceConfigService::get().loadConfig(*configFilePath, false); + std::thread([weak = state->weak_from_this()]() { std::this_thread::sleep_for(std::chrono::seconds(60)); if (auto state = weak.lock()) { @@ -226,4 +409,3 @@ void HttpService::Impl::handlePostConfigRequest( } } // namespace mapget - diff --git a/libs/http-service/src/tiles-ws-controller.cpp b/libs/http-service/src/tiles-ws-controller.cpp index 2632e632..2fe756d6 100644 --- a/libs/http-service/src/tiles-ws-controller.cpp +++ b/libs/http-service/src/tiles-ws-controller.cpp @@ -122,6 +122,25 @@ enum class ClientRequestUpdateMode return "Unknown"; } +[[nodiscard]] std::string_view noDataSourceReasonToString(NoDataSourceReason reason) +{ + switch (reason) { + case NoDataSourceReason::EmptySources: + return "emptySources"; + case NoDataSourceReason::AllSourcesDisabled: + return "allSourcesDisabled"; + case NoDataSourceReason::DatasourceInitializationFailed: + return "datasourceInitializationFailed"; + case NoDataSourceReason::MissingMapOrLayer: + return "missingMapOrLayer"; + case NoDataSourceReason::NoConfig: + return "noConfig"; + case NoDataSourceReason::None: + break; + } + return ""; +} + /// Convert tile load-state enum values to stable UI-facing strings. [[nodiscard]] std::string_view loadStateToString(TileLayer::LoadState s) { @@ -648,6 +667,7 @@ class TilesWsSession : public std::enable_shared_from_this nextRequestInfos.push_back(RequestInfo{ .mapId = parsed.request.mapId, .layerId = parsed.request.layerId, + .noDataSourceReason = parsed.context.noDataSourceReason_, }); std::vector> tileIdsByNextStageToFetch; @@ -837,6 +857,7 @@ class TilesWsSession : public std::enable_shared_from_this { std::string mapId; std::string layerId; + NoDataSourceReason noDataSourceReason = NoDataSourceReason::None; }; /// One queued websocket frame plus metadata used for bookkeeping. @@ -1455,6 +1476,12 @@ class TilesWsSession : public std::enable_shared_from_this reqJson["layerId"] = requestInfos_[i].layerId; reqJson["status"] = static_cast>(status); reqJson["statusText"] = std::string(requestStatusToString(status)); + if (status == RequestStatus::NoDataSource) { + auto reason = noDataSourceReasonToString(requestInfos_[i].noDataSourceReason); + if (!reason.empty()) { + reqJson["noDataSourceReason"] = std::string(reason); + } + } requestsJson.push_back(std::move(reqJson)); } } diff --git a/libs/model/CMakeLists.txt b/libs/model/CMakeLists.txt index 6fffa562..e3d2acb0 100644 --- a/libs/model/CMakeLists.txt +++ b/libs/model/CMakeLists.txt @@ -4,6 +4,8 @@ add_library(mapget-model STATIC include/mapget/model/stringpool.h include/mapget/model/layer.h include/mapget/model/featurelayer.h + include/mapget/model/geojson-import.h + include/mapget/model/json-compare.h include/mapget/model/info.h include/mapget/model/feature.h include/mapget/model/attr.h @@ -29,6 +31,8 @@ add_library(mapget-model STATIC src/stringpool.cpp src/layer.cpp src/featurelayer.cpp + src/geojson-import.cpp + src/json-compare.cpp src/info.cpp src/feature.cpp src/attr.cpp diff --git a/libs/model/include/mapget/model/featureid.h b/libs/model/include/mapget/model/featureid.h index 16cee92c..cf185332 100644 --- a/libs/model/include/mapget/model/featureid.h +++ b/libs/model/include/mapget/model/featureid.h @@ -17,7 +17,10 @@ using Object = simfil::Object; using Array = simfil::Array; /** - * Unique feature ID + * Canonical feature identifier tied to a layer's configured id compositions. + * + * The string form is dot-separated and may elide a shared tile prefix when the + * backing storage uses `useCommonTilePrefix_`. */ class FeatureId : public simfil::MandatoryDerivedModelNodeBase { @@ -33,16 +36,27 @@ class FeatureId : public simfil::MandatoryDerivedModelNodeBase /** Get the feature ID's type id. */ [[nodiscard]] std::string_view typeId() const; + /** Get the effective map id referenced by this feature id. */ + [[nodiscard]] std::string mapId() const; + + /** + * Get the explicitly stored external map id for detached references. + * Returns nullopt when the reference points into the current tile's map. + */ + [[nodiscard]] std::optional externalMapId() const; + /** Get all id-part key-value-pairs (including the common prefix). */ [[nodiscard]] KeyValueViewPairs keyValuePairs() const; + /** Compact serialized representation of a feature id node. */ struct Data { - MODEL_COLUMN_TYPE(8); + MODEL_COLUMN_TYPE(12); bool useCommonTilePrefix_ = false; uint8_t idCompositionIndex_ = 0; simfil::StringId typeId_ = 0; simfil::ArrayIndex idPartValues_ = simfil::InvalidArrayIndex; + simfil::StringId extMapId_ = simfil::StringPool::Empty; }; protected: @@ -77,4 +91,23 @@ class FeatureId : public simfil::MandatoryDerivedModelNodeBase std::vector visibleValueIndices_; }; +/** Parsed representation of a canonical feature-id string. */ +struct ParsedFeatureId +{ + std::string typeId_; + KeyValuePairs keyValuePairs_; + uint8_t idCompositionIndex_ = 0; +}; + +/** + * Parse a canonical dot-separated feature-id string as emitted by FeatureId::toString(). + * String-valued id parts are percent-unescaped before datatype validation. + * Ambiguous matches are rejected so callers can resolve a single composition. + */ +bool parseFeatureIdString( + std::string_view featureId, + LayerInfo const& layerInfo, + ParsedFeatureId& result, + std::string* error = nullptr); + } diff --git a/libs/model/include/mapget/model/featurelayer.h b/libs/model/include/mapget/model/featurelayer.h index 9f7a4e62..a8eac387 100644 --- a/libs/model/include/mapget/model/featurelayer.h +++ b/libs/model/include/mapget/model/featurelayer.h @@ -17,6 +17,7 @@ #include "stringpool.h" #include "layer.h" #include "sourceinfo.h" +#include "geojson-import.h" #include "feature.h" #include "attrlayer.h" #include "relation.h" @@ -28,6 +29,21 @@ namespace mapget { +/** + * Optional GLB payload stored alongside a TileFeatureLayer. + * + * The attachment lives on TileFeatureLayer rather than on the base TileLayer + * because only feature tiles need binary side payloads today. The model is + * intentionally opinionated: at most one GLB payload may be attached to a tile. + */ +struct TileGlbAttachment +{ + std::string name_; + std::vector bytes_; + + [[nodiscard]] nlohmann::json toJsonMetadata() const; +}; + /** * The TileFeatureLayer class represents a specific map layer * within a map tile. It is a container for map features. @@ -64,6 +80,7 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool // Keep ModelPool::resolve overloads visible alongside the override below. using ModelPool::resolve; using Ptr = std::shared_ptr; + static constexpr std::string_view GLB_ATTACHMENT_MIME_TYPE = "model/gltf-binary"; struct CloneCacheKey { @@ -149,10 +166,13 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool * Create a new feature id. Use this function to create a reference to another * feature. The created feature id will not use the common feature id prefix from * this tile feature layer, since the reference may be to a feature stored in a - * different tile. + * different tile or map. When `externalMapId` is set to another map, the detached + * reference keeps that map id for binary serialization and JSON export. */ model_ptr newFeatureId( - std::string_view const& typeId, KeyValueViewPairs const& featureIdParts); + std::string_view const& typeId, + KeyValueViewPairs const& featureIdParts, + std::optional externalMapId = std::nullopt); /** * Create a new relation. Use this function to create a named reference to another @@ -207,13 +227,13 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool model_ptr newValidityCollection(size_t initialCapacity = 2, bool fixedSize=false); /** - * Internal validity upgrade helpers used by Validity. + * Upgrade one compact simple-validity occurrence in-place to full storage. */ simfil::ModelNodeAddress materializeSimpleValidity( simfil::ModelNodeAddress simpleAddress, + simfil::ArrayIndex ownerMembers, + uint32_t ownerElementIndex, Validity::Direction direction); - std::optional upgradedSimpleValidityAddress( - simfil::ModelNodeAddress simpleAddress) const; /** * Return type for begin() and end() methods to support range-based @@ -257,6 +277,20 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool /** Convert to (Geo-) JSON. */ nlohmann::json toJson() const override; + /** Import a mapget-flavoured or best-effort GeoJSON feature collection into this tile. */ + void fromJson(nlohmann::json const& json, GeoJsonImportOptions const& options = {}); + + /** + * Inspect or replace the optional tile-level GLB attachment without + * inlining payload bytes. + * + * `GeomType::GltfNodeIndex` always refers to nodes inside this GLB + * attachment when present. + */ + [[nodiscard]] TileGlbAttachment const* glbAttachment() const; + void setGlbAttachment(std::string name, std::vector bytes); + void clearGlbAttachment(); + /** Report serialized size stats for feature-layer data and model-pool columns. */ [[nodiscard]] nlohmann::json serializationSizeStats() const; @@ -280,6 +314,8 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool /** Optional staged-loading index (0-based) for this feature tile. */ [[nodiscard]] std::optional stage() const override; + + /** Store or clear the tile-stage marker without affecting contained geometries. */ void setStage(std::optional stage) override; /** @@ -395,6 +431,8 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool LineGeometries, PolygonGeometries, MeshGeometries, + AabbGeometries, + GltfNodeIndexGeometries, GeometryViews, GeometryCollections, Mesh, @@ -409,6 +447,10 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool ValidityCollections, FeatureRelationsView, GeometryArrayView, + GeometryBoundsInfoView, + GeometryBoundsPolygonCoordinatesView, + GeometryBoundsRingView, + GeometryPointView, // Compact validity form without backing struct storage. // Direction is encoded in ModelNodeAddress::index(). SimpleValidity, diff --git a/libs/model/include/mapget/model/geojson-import.h b/libs/model/include/mapget/model/geojson-import.h new file mode 100644 index 00000000..3d1ffba9 --- /dev/null +++ b/libs/model/include/mapget/model/geojson-import.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +#include + +namespace mapget +{ + +class TileFeatureLayer; + +/** + * Controls whether GeoJSON import behaves like a strict mapget roundtrip + * reader or as a permissive adapter for generic external GeoJSON. + */ +struct GeoJsonImportOptions +{ + /** Require mapget-style structure and reject lossy or ambiguous input. */ + bool strict_ = true; + + /** Feature type to assume when the GeoJSON feature does not carry `typeId`. */ + std::optional fallbackFeatureType_; + + /** + * In best-effort mode, treat object-valued entries under `properties` + * as attribute-layer payloads instead of plain JSON attributes. + */ + bool objectPropertiesAsAttributeLayers_ = false; +}; + +/** + * Import a FeatureCollection into an empty TileFeatureLayer. + * + * Depending on `options`, this either expects mapget's JSON profile or + * performs best-effort adaptation from generic GeoJSON. + */ +void importGeoJson( + TileFeatureLayer& tile, + nlohmann::json const& geoJson, + GeoJsonImportOptions const& options = {}); + +} diff --git a/libs/model/include/mapget/model/geometry-data.h b/libs/model/include/mapget/model/geometry-data.h index a2655267..f52be95b 100644 --- a/libs/model/include/mapget/model/geometry-data.h +++ b/libs/model/include/mapget/model/geometry-data.h @@ -14,9 +14,46 @@ enum class GeomType : uint8_t { Points, // Point-cloud Line, // Line-string Polygon, // Auto-closed polygon - Mesh // Collection of triangles + Mesh, // Collection of triangles + AABB, // Axis-aligned bounding box: [origin, size] + GltfNodeIndex // Index into TileFeatureLayer::glbAttachment() plus per-node AABB bounds }; +enum class GeometryPointViewKind : uint8_t { + RawSize, + BoundsOrigin, + BoundsSize, + BoundsCorner0, + BoundsCorner1, + BoundsCorner2, + BoundsCorner3, + BoundsCorner4, +}; + +inline int64_t encodeGeometryHelperData( + simfil::ModelNodeAddress baseGeometry, + uint8_t payload = 0) +{ + return (static_cast(baseGeometry.column()) << 8) | payload; +} + +inline uint8_t decodeGeometryHelperBaseColumn(int64_t encoded) +{ + return static_cast((encoded >> 8) & 0xff); +} + +inline simfil::ModelNodeAddress decodeGeometryHelperBaseAddress( + simfil::ModelNodeAddress helperAddress, + int64_t encoded) +{ + return {decodeGeometryHelperBaseColumn(encoded), helperAddress.index()}; +} + +inline GeometryPointViewKind decodeGeometryPointViewKind(int64_t encoded) +{ + return static_cast(encoded & 0xff); +} + struct GeometryViewData { MODEL_COLUMN_TYPE(20); diff --git a/libs/model/include/mapget/model/geometry.h b/libs/model/include/mapget/model/geometry.h index 4f0884ec..92ec118e 100644 --- a/libs/model/include/mapget/model/geometry.h +++ b/libs/model/include/mapget/model/geometry.h @@ -8,6 +8,7 @@ #include "sourcedatareference.h" #include "sourceinfo.h" #include "merged-array-view.h" +#include "nlohmann/json_fwd.hpp" #include #include @@ -31,6 +32,9 @@ namespace mapget class TileFeatureLayer; class GeometryArrayView; +class BoundsInfoNode; +class BoundsPolygonCoordinatesNode; +class BoundsRingNode; /** * Small interface container type which may be used @@ -55,6 +59,9 @@ class Geometry final : public simfil::MandatoryDerivedModelNodeBase sourceDataReferences() const; @@ -63,6 +70,24 @@ class Geometry final : public simfil::MandatoryDerivedModelNodeBase stage() const; + /** Persist an explicit geometry stage override used by JSON import/export. */ + void setStage(std::optional geometryStage); + /** Iterate over all Points in the geometry. * @param callback Function which is called for each contained point. * Must return true to continue iteration, false to abort iteration. @@ -137,6 +165,7 @@ class Geometry final : public simfil::MandatoryDerivedModelNodeBase GeomType type, std::optional stageOverride = std::nullopt) const; + [[nodiscard]] nlohmann::json toJson() const override; + /** Iterate over all geometries at the preferred stage. */ template bool forEachGeometryAtPreferredStage( @@ -348,6 +379,67 @@ class PointBufferNode final : public simfil::MandatoryDerivedModelNodeBase +{ +public: + explicit BoundsInfoNode(simfil::detail::mp_key key) + : simfil::MandatoryDerivedModelNodeBase(key) {} + + [[nodiscard]] ValueType type() const override; + [[nodiscard]] ModelNode::Ptr at(int64_t) const override; + [[nodiscard]] uint32_t size() const override; + [[nodiscard]] ModelNode::Ptr get(const StringId&) const override; + [[nodiscard]] StringId keyAt(int64_t) const override; + bool iterate(IterCallback const& cb) const override; // NOLINT (allow discard) + + BoundsInfoNode() = delete; + BoundsInfoNode(ModelNode const& baseNode, simfil::detail::mp_key key); + +private: + ModelNodeAddress baseGeometryAddress_; +}; + +class BoundsPolygonCoordinatesNode final + : public simfil::MandatoryDerivedModelNodeBase +{ +public: + explicit BoundsPolygonCoordinatesNode(simfil::detail::mp_key key) + : simfil::MandatoryDerivedModelNodeBase(key) {} + + [[nodiscard]] ValueType type() const override; + [[nodiscard]] ModelNode::Ptr at(int64_t) const override; + [[nodiscard]] uint32_t size() const override; + [[nodiscard]] ModelNode::Ptr get(const StringId&) const override; + [[nodiscard]] StringId keyAt(int64_t) const override; + bool iterate(IterCallback const& cb) const override; // NOLINT (allow discard) + + BoundsPolygonCoordinatesNode() = delete; + BoundsPolygonCoordinatesNode(ModelNode const& baseNode, simfil::detail::mp_key key); + +private: + ModelNodeAddress baseGeometryAddress_; +}; + +class BoundsRingNode final : public simfil::MandatoryDerivedModelNodeBase +{ +public: + explicit BoundsRingNode(simfil::detail::mp_key key) + : simfil::MandatoryDerivedModelNodeBase(key) {} + + [[nodiscard]] ValueType type() const override; + [[nodiscard]] ModelNode::Ptr at(int64_t) const override; + [[nodiscard]] uint32_t size() const override; + [[nodiscard]] ModelNode::Ptr get(const StringId&) const override; + [[nodiscard]] StringId keyAt(int64_t) const override; + bool iterate(IterCallback const& cb) const override; // NOLINT (allow discard) + + BoundsRingNode() = delete; + BoundsRingNode(ModelNode const& baseNode, simfil::detail::mp_key key); + +private: + ModelNodeAddress baseGeometryAddress_; +}; + /** Polygon Node */ class PolygonNode final : public simfil::MandatoryDerivedModelNodeBase diff --git a/libs/model/include/mapget/model/json-compare.h b/libs/model/include/mapget/model/json-compare.h new file mode 100644 index 00000000..3b1d058a --- /dev/null +++ b/libs/model/include/mapget/model/json-compare.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#include "nlohmann/json.hpp" + +namespace mapget +{ + +/** Compare two floating-point values using mixed absolute/relative tolerance. */ +bool nearlyEqual(double a, double b, double epsilon); + +/** + * Compare arbitrary JSON values while tolerating small floating-point drift. + * + * When `errors` is provided, mismatches are appended as human-readable paths. + */ +bool compareJsonWithTolerance( + nlohmann::json const& expected, + nlohmann::json const& actual, + double floatTolerance = 1e-5, + std::vector* errors = nullptr); + +/** + * Compare only the top-level `features` arrays of two feature collections. + * + * This is useful for snapshot tests where envelope metadata is intentionally + * ignored but per-feature structure must stay stable. + */ +bool compareFeatureCollectionJsonWithTolerance( + nlohmann::json const& expected, + nlohmann::json const& actual, + double floatTolerance = 1e-5, + std::vector* errors = nullptr); + +/** Join collected comparison errors into a newline-separated debug string. */ +[[nodiscard]] std::string formatJsonComparisonErrors(std::vector const& errors); + +} diff --git a/libs/model/include/mapget/model/pointnode.h b/libs/model/include/mapget/model/pointnode.h index 12f924ea..6f884587 100644 --- a/libs/model/include/mapget/model/pointnode.h +++ b/libs/model/include/mapget/model/pointnode.h @@ -35,6 +35,7 @@ class PointNode final : public simfil::MandatoryDerivedModelNodeBase; diff --git a/libs/model/include/mapget/model/stringpool.h b/libs/model/include/mapget/model/stringpool.h index 337200c8..58eea016 100644 --- a/libs/model/include/mapget/model/stringpool.h +++ b/libs/model/include/mapget/model/stringpool.h @@ -52,6 +52,10 @@ struct StringPool : public simfil::StringPool EndStr, PointStr, FeatureIdStr, + AabbStr, + OriginStr, + SizeStr, + GltfNodeIndexStr, FromStr, ToStr, ConnectedEndStr, diff --git a/libs/model/include/mapget/model/validity.h b/libs/model/include/mapget/model/validity.h index 3933ef29..8f088990 100644 --- a/libs/model/include/mapget/model/validity.h +++ b/libs/model/include/mapget/model/validity.h @@ -137,6 +137,11 @@ class Validity : public simfil::ProceduralObject<7, Validity, TileFeatureLayer> simfil::ModelConstPtr layer, simfil::ModelNodeAddress a, simfil::detail::mp_key key); + Validity(Direction direction, + simfil::ModelConstPtr layer, + simfil::ModelNodeAddress a, + simfil::ScalarValueType runtimeData, + simfil::detail::mp_key key); Validity(Data* data, simfil::ModelConstPtr layer, simfil::ModelNodeAddress a, @@ -269,6 +274,9 @@ struct MultiValidity : public simfil::BaseArray */ model_ptr newDirection(Validity::Direction direction = Validity::Empty); + [[nodiscard]] ModelNode::Ptr at(int64_t i) const override; + bool iterate(ModelNode::IterCallback const& cb) const override; // NOLINT (allow discard) + private: using simfil::BaseArray::BaseArray; }; diff --git a/libs/model/src/feature.cpp b/libs/model/src/feature.cpp index 923aa825..ea20d0bd 100644 --- a/libs/model/src/feature.cpp +++ b/libs/model/src/feature.cpp @@ -463,9 +463,16 @@ void Feature::updateFields() const { } } - // Add feature-specific id-part fields - for (auto const& [idPartName, value] : idNode->fields()) { - fields_.emplace_back(idPartName, value); + // Keep regular feature ids scalar in the node protocol. Feature export still + // needs the explicit id-part fields, so materialize them from the typed + // feature-id storage instead of relying on object-style child traversal. + if (idNode->values_) { + auto const limit = std::min(idNode->partNames_.size(), idNode->visibleValueIndices_.size()); + for (size_t i = 0; i < limit; ++i) { + if (auto value = idNode->values_->at(static_cast(idNode->visibleValueIndices_[i]))) { + fields_.emplace_back(idNode->partNames_[i], value); + } + } } // Add other fields @@ -558,7 +565,9 @@ void Feature::materializeGeometryCollection() return column == Col::PointGeometries || column == Col::LineGeometries || column == Col::PolygonGeometries || - column == Col::MeshGeometries; + column == Col::MeshGeometries || + column == Col::AabbGeometries || + column == Col::GltfNodeIndexGeometries; }; auto currentGeomAddress = geometryNodeAddress(); if (!currentGeomAddress || diff --git a/libs/model/src/featureid.cpp b/libs/model/src/featureid.cpp index 88a8489d..7d3c12a1 100644 --- a/libs/model/src/featureid.cpp +++ b/libs/model/src/featureid.cpp @@ -2,7 +2,11 @@ #include "featurelayer.h" #include +#include +#include #include +#include +#include #include @@ -13,6 +17,7 @@ namespace mapget namespace { +/** Resolve the concrete id composition used by a stored feature-id node. */ std::vector const* resolveComposition( TileFeatureLayer const& model, simfil::StringId typeId, @@ -34,6 +39,7 @@ std::vector const* resolveComposition( return &typeInfo->uniqueIdCompositions_[compositionIndex]; } +/** Build the externally visible id-part layout after removing an optional tile prefix. */ void resolveVisiblePartLayout( TileFeatureLayer const& model, FeatureId::Data const& data, @@ -91,6 +97,8 @@ void resolveVisiblePartLayout( prefixFeatureIdParts, prefixFeatureIdParts.size()); if (!matchEndIndex) { + // A stored prefix that no longer matches the schema would make the + // visible id parts misleading, so we expose no parts instead. return; } localStartIndex = *matchEndIndex; @@ -120,6 +128,7 @@ void resolveVisiblePartLayout( } template +/** Forward a typed id-part value while rejecting node types that cannot appear in ids. */ void appendTypedKeyValue( TileFeatureLayer const& model, simfil::StringId key, @@ -145,6 +154,157 @@ void appendTypedKeyValue( valueNode->value()); } +/** Check whether a character can participate in a percent escape. */ +[[nodiscard]] bool isHexDigit(char ch) +{ + return std::isdigit(static_cast(ch)) || + (ch >= 'a' && ch <= 'f') || + (ch >= 'A' && ch <= 'F'); +} + +/** Decode one hexadecimal digit used by feature-id escaping. */ +[[nodiscard]] uint8_t hexValue(char ch) +{ + if (ch >= '0' && ch <= '9') { + return static_cast(ch - '0'); + } + if (ch >= 'a' && ch <= 'f') { + return static_cast(10 + (ch - 'a')); + } + return static_cast(10 + (ch - 'A')); +} + +/** Escape separators and escape markers inside string-valued id parts. */ +[[nodiscard]] std::string escapeFeatureIdPart(std::string_view input) +{ + std::string result; + result.reserve(input.size()); + for (char ch : input) { + if (ch == '.') { + // Dots delimit id parts in the canonical string form. + result.append("%2E"); + } + else if (ch == '%') { + // Existing escape markers must be preserved literally. + result.append("%25"); + } + else { + result.push_back(ch); + } + } + return result; +} + +/** Reverse percent escaping for a single canonical feature-id token. */ +[[nodiscard]] bool unescapeFeatureIdPart( + std::string_view input, + std::string& output, + std::string* error) +{ + output.clear(); + output.reserve(input.size()); + for (size_t i = 0; i < input.size(); ++i) { + char const ch = input[i]; + if (ch != '%') { + output.push_back(ch); + continue; + } + if (i + 2 >= input.size() || !isHexDigit(input[i + 1]) || !isHexDigit(input[i + 2])) { + if (error) { + *error = fmt::format("Malformed percent escape in feature id token '{}'.", input); + } + return false; + } + auto const decoded = static_cast((hexValue(input[i + 1]) << 4U) | hexValue(input[i + 2])); + output.push_back(decoded); + i += 2; + } + return true; +} + +/** Split a canonical feature-id string into type and id-part tokens. */ +[[nodiscard]] std::vector splitFeatureIdTokens(std::string_view input) +{ + std::vector tokens; + size_t start = 0; + for (size_t i = 0; i <= input.size(); ++i) { + if (i == input.size() || input[i] == '.') { + tokens.push_back(input.substr(start, i - start)); + start = i + 1; + } + } + return tokens; +} + +struct CompositionParseState +{ + KeyValuePairs values; +}; + +/** Try to parse one id composition, including optional parts that may consume no token. */ +[[nodiscard]] bool tryParseCompositionRecursive( + std::vector const& composition, + std::vector const& tokens, + size_t partIndex, + size_t tokenIndex, + CompositionParseState const& current, + std::vector& results, + std::string* error) +{ + if (partIndex == composition.size()) { + if (tokenIndex == tokens.size()) { + results.push_back(current); + return true; + } + return false; + } + + auto const& part = composition[partIndex]; + auto matched = false; + + if (part.isOptional_) { + // Optional parts are explored both as present and as omitted so we can + // disambiguate compositions solely from the canonical string form. + matched = tryParseCompositionRecursive( + composition, + tokens, + partIndex + 1, + tokenIndex, + current, + results, + error) || matched; + } + + if (tokenIndex >= tokens.size()) { + return matched; + } + + std::string decoded; + if (!unescapeFeatureIdPart(tokens[tokenIndex], decoded, error)) { + return false; + } + + std::variant parsedValue = decoded; + std::string localError; + if (!part.validate(parsedValue, &localError)) { + // Datatype mismatches do not fail the whole search immediately because a + // different composition may still accept the same token sequence. + return matched; + } + + auto next = current; + next.values.emplace_back(part.idPartLabel_, std::move(parsedValue)); + return tryParseCompositionRecursive( + composition, + tokens, + partIndex + 1, + tokenIndex + 1, + next, + results, + error) || matched; +} + +/** Append one node value to the canonical dot-separated feature-id string. */ void appendNodeValueToString(std::string& out, simfil::ModelNode::Ptr const& node) { if (!node) { @@ -162,6 +322,10 @@ void appendNodeValueToString(std::string& out, simfil::ModelNode::Ptr const& nod if constexpr (std::is_same_v) { fmt::format_to(std::back_inserter(out), FMT_STRING(".{:d}"), v); } + else if constexpr (std::is_same_v || std::is_same_v) { + // String-valued parts must escape canonical separators before joining. + fmt::format_to(std::back_inserter(out), FMT_STRING(".{}"), escapeFeatureIdPart(v)); + } else { fmt::format_to(std::back_inserter(out), FMT_STRING(".{}"), v); } @@ -169,7 +333,24 @@ void appendNodeValueToString(std::string& out, simfil::ModelNode::Ptr const& nod }, node->value()); } + +/** Materialize one exported reference field for external-map feature ids. */ +[[nodiscard]] simfil::ModelNode::Ptr referenceFieldNode( + FeatureId const& featureId, + simfil::StringId field) +{ + auto owner = featureId.model().shared_from_this(); + if (field == StringPool::IdStr) { + return model_ptr::make(featureId.toString(), owner); + } + if (field == StringPool::MapIdStr) { + if (auto mapId = featureId.externalMapId()) { + return model_ptr::make(std::string(*mapId), owner); + } + } + return {}; } +} // namespace FeatureId::FeatureId(FeatureId::Data& data, simfil::ModelConstPtr l, @@ -213,6 +394,28 @@ std::string_view FeatureId::typeId() const return "err-unresolved-typename"; } +std::string FeatureId::mapId() const +{ + if (auto mapId = externalMapId()) { + return std::string(*mapId); + } + return model().mapId(); +} + +std::optional FeatureId::externalMapId() const +{ + if (data_.extMapId_ == simfil::StringPool::Empty) { + return std::nullopt; + } + + if (auto resolved = model().strings()->resolve(data_.extMapId_)) { + return *resolved; + } + + raise("FeatureId external map id is not known to string pool."); + return std::nullopt; +} + std::string FeatureId::toString() const { std::string result(typeId()); @@ -239,48 +442,60 @@ std::string FeatureId::toString() const simfil::ValueType FeatureId::type() const { - return simfil::ValueType::String; + // Only external references need structural object access; all other ids + // stay canonical strings to preserve the historical feature-id surface. + return externalMapId().has_value() ? simfil::ValueType::Object : simfil::ValueType::String; } simfil::ScalarValueType FeatureId::value() const { + if (externalMapId()) { + return {}; + } return toString(); } simfil::ModelNode::Ptr FeatureId::at(int64_t i) const { - if (i < 0 || !values_ || i >= static_cast(visibleValueIndices_.size())) { + if (!externalMapId()) { + return {}; + } + switch (i) { + case 0: + return referenceFieldNode(*this, StringPool::IdStr); + case 1: + return referenceFieldNode(*this, StringPool::MapIdStr); + default: return {}; } - return values_->at(static_cast(visibleValueIndices_[static_cast(i)])); } uint32_t FeatureId::size() const { - return static_cast(visibleValueIndices_.size()); + return externalMapId() ? 2U : 0U; } simfil::ModelNode::Ptr FeatureId::get(const simfil::StringId& f) const { - if (!values_) { + if (!externalMapId()) { return {}; } - - for (size_t i = 0; i < partNames_.size(); ++i) { - if (partNames_[i] == f && i < visibleValueIndices_.size()) { - return values_->at(static_cast(visibleValueIndices_[i])); - } - } - - return {}; + return referenceFieldNode(*this, f); } simfil::StringId FeatureId::keyAt(int64_t i) const { - if (i < 0 || i >= static_cast(partNames_.size())) { + if (!externalMapId()) { + return {}; + } + switch (i) { + case 0: + return StringPool::IdStr; + case 1: + return StringPool::MapIdStr; + default: return {}; } - return partNames_[static_cast(i)]; } bool FeatureId::iterate(const simfil::ModelNode::IterCallback& cb) const @@ -321,4 +536,78 @@ KeyValueViewPairs FeatureId::keyValuePairs() const return result; } +bool parseFeatureIdString( + std::string_view featureId, + LayerInfo const& layerInfo, + ParsedFeatureId& result, + std::string* error) +{ + result = {}; + + auto const tokens = splitFeatureIdTokens(featureId); + if (tokens.empty() || tokens.front().empty()) { + if (error) { + *error = "Feature id must start with a non-empty type id."; + } + return false; + } + + auto const typeId = std::string(tokens.front()); + auto const* typeInfo = layerInfo.getTypeInfo(typeId, false); + if (!typeInfo) { + if (error) { + *error = fmt::format("Could not find feature type {}", typeId); + } + return false; + } + + std::vector> matches; + std::string localError; + for (uint32_t compositionIndex = 0; + compositionIndex < typeInfo->uniqueIdCompositions_.size(); + ++compositionIndex) { + auto const& composition = typeInfo->uniqueIdCompositions_[compositionIndex]; + std::vector parsedStates; + CompositionParseState emptyState{}; + if (!tryParseCompositionRecursive( + composition, + std::vector(tokens.begin() + 1, tokens.end()), + 0, + 0, + emptyState, + parsedStates, + &localError)) { + continue; + } + for (auto& parsed : parsedStates) { + // The parser keeps all valid matches so ambiguity can be reported explicitly. + matches.emplace_back(static_cast(std::min(compositionIndex, 255U)), std::move(parsed)); + } + } + + if (matches.empty()) { + if (error) { + *error = localError.empty() + ? fmt::format("Could not parse feature id '{}' for type '{}'.", featureId, typeId) + : localError; + } + return false; + } + + if (matches.size() > 1) { + if (error) { + *error = fmt::format( + "Feature id '{}' matches multiple id compositions of type '{}'.", + featureId, + typeId); + } + return false; + } + + result.typeId_ = typeId; + result.idCompositionIndex_ = matches.front().first; + result.keyValuePairs_ = std::move(matches.front().second.values); + return true; +} + } diff --git a/libs/model/src/featurelayer.cpp b/libs/model/src/featurelayer.cpp index 88b66410..0a84158b 100644 --- a/libs/model/src/featurelayer.cpp +++ b/libs/model/src/featurelayer.cpp @@ -47,6 +47,13 @@ void serialize(S& s, glm::vec3& v) { s.value4b(v.z); } +template +void serialize(S& s, mapget::TileGlbAttachment& attachment) +{ + s.text1b(attachment.name_, std::numeric_limits::max()); + s.container1b(attachment.bytes_, std::numeric_limits::max()); +} + } namespace @@ -90,13 +97,17 @@ bool isBufferedGeometryColumn(uint8_t column) using Col = TileFeatureLayer::ColumnId; return column == Col::LineGeometries || column == Col::PolygonGeometries || - column == Col::MeshGeometries; + column == Col::MeshGeometries || + column == Col::AabbGeometries || + column == Col::GltfNodeIndexGeometries; } bool isBaseGeometryColumn(uint8_t column) { using Col = TileFeatureLayer::ColumnId; - return column == Col::PointGeometries || isBufferedGeometryColumn(column); + return column == Col::PointGeometries || + column == Col::GltfNodeIndexGeometries || + isBufferedGeometryColumn(column); } GeomType geometryTypeForColumn(uint8_t column) @@ -111,6 +122,10 @@ GeomType geometryTypeForColumn(uint8_t column) return GeomType::Polygon; case Col::MeshGeometries: return GeomType::Mesh; + case Col::AabbGeometries: + return GeomType::AABB; + case Col::GltfNodeIndexGeometries: + return GeomType::GltfNodeIndex; default: raiseFmt("Unexpected geometry column {}.", column); return GeomType::Points; @@ -220,6 +235,7 @@ struct FeatureAddrWithIdHash struct TileFeatureLayer::Impl { ModelNodeAddress featureIdPrefix_; Point geometryAnchor_{}; + std::optional glbAttachment_; simfil::ModelColumn features_; simfil::ModelColumn complexFeatureData_; @@ -244,7 +260,6 @@ struct TileFeatureLayer::Impl { */ simfil::ModelColumn featureHashIndex_; bool featureHashIndexNeedsSorting_ = false; - std::unordered_map upgradedSimpleValidityAddresses_; void sortFeatureHashIndex() { if (!featureHashIndexNeedsSorting_) @@ -262,6 +277,7 @@ struct TileFeatureLayer::Impl { s.value8b(geometryAnchor_.x); s.value8b(geometryAnchor_.y); s.value8b(geometryAnchor_.z); + s.ext(glbAttachment_, bitsery::ext::StdOptional{}); s.object(features_); s.object(complexFeatureData_); s.object(complexFeatureDataRefs_); @@ -288,6 +304,15 @@ struct TileFeatureLayer::Impl { }; +nlohmann::json TileGlbAttachment::toJsonMetadata() const +{ + return nlohmann::json::object({ + {"name", name_}, + {"mimeType", std::string(TileFeatureLayer::GLB_ATTACHMENT_MIME_TYPE)}, + {"sizeBytes", bytes_.size()}, + }); +} + TileFeatureLayer::TileFeatureLayer( TileId tileId, std::string const& nodeId, @@ -353,6 +378,27 @@ void TileFeatureLayer::setStage(std::optional stage) stage_ = stage; } +TileGlbAttachment const* TileFeatureLayer::glbAttachment() const +{ + return impl_->glbAttachment_ ? &*impl_->glbAttachment_ : nullptr; +} + +void TileFeatureLayer::setGlbAttachment(std::string name, std::vector bytes) +{ + if (name.empty()) { + raise("GLB attachment name must not be empty."); + } + impl_->glbAttachment_ = TileGlbAttachment{ + std::move(name), + std::move(bytes), + }; +} + +void TileFeatureLayer::clearGlbAttachment() +{ + impl_->glbAttachment_.reset(); +} + void TileFeatureLayer::setExpectedFeatureSequence(std::vector expectedFeatureIds) { expectedFeatureIds_ = std::move(expectedFeatureIds); @@ -647,6 +693,8 @@ simfil::model_ptr TileFeatureLayer::newFeature( value->value()); } } + // Validation runs against the full logical feature id, even though only the + // non-prefix suffix is materialized into the compact feature storage. fullFeatureIdParts.insert(fullFeatureIdParts.end(), featureIdParts.begin(), featureIdParts.end()); if (!layerInfo_->validFeatureId(typeId, fullFeatureIdParts, true)) { @@ -656,6 +704,8 @@ simfil::model_ptr TileFeatureLayer::newFeature( idPartsToString(fullFeatureIdParts))); } auto const& primaryIdComposition = getPrimaryIdComposition(typeId); + // Stored feature ids omit the common tile prefix to save space, so we first + // determine where the feature-local suffix starts within the composition. auto const localStartIndex = prefixFeatureIdParts.empty() ? 0U : *IdPart::compositionMatchEndIndex( @@ -708,6 +758,8 @@ simfil::model_ptr TileFeatureLayer::newFeature( auto const& expectedFeatureId = expectedFeatureIds_[featureIndex]; auto const actualFeatureId = result.id()->toString(); if (actualFeatureId != expectedFeatureId) { + // Overlay validation is sequence-based on purpose so stage imports + // fail immediately when converters reorder or drop features. raiseFmt( "Feature sequence mismatch at index {}: expected {}, got {}.", featureIndex, @@ -733,7 +785,8 @@ simfil::model_ptr TileFeatureLayer::newFeature( model_ptr TileFeatureLayer::newFeatureId( const std::string_view& typeId, - const KeyValueViewPairs& featureIdParts) + const KeyValueViewPairs& featureIdParts, + std::optional externalMapId) { if (!layerInfo_->validFeatureId(typeId, featureIdParts, false)) { raise(fmt::format( @@ -750,11 +803,21 @@ TileFeatureLayer::newFeatureId( *layerInfo_->matchingFeatureIdCompositionIndex(typeId, featureIdParts, false); auto const& composition = layerInfo_->getTypeInfo(typeId)->uniqueIdCompositions_[idCompositionIndex]; + simfil::StringId externalMapIdStringId = simfil::StringPool::Empty; + if (externalMapId && *externalMapId != mapId()) { + auto storedMapId = strings()->emplace(*externalMapId); + if (!storedMapId) { + raise(storedMapId.error().message); + } + // Local references omit the redundant map payload so legacy JSON stays compact. + externalMapIdStringId = *storedMapId; + } impl_->featureIds_.emplace_back(FeatureId::Data{ false, idCompositionIndex, *typeIdStringId, - idPartValuesToArrayIndex(*this, composition, featureIdParts) + idPartValuesToArrayIndex(*this, composition, featureIdParts), + externalMapIdStringId, }); return FeatureId( impl_->featureIds_.back(), @@ -891,6 +954,16 @@ model_ptr TileFeatureLayer::newGeometry( auto const vertexArray = impl_->pointBuffers_.new_array(initialCapacity, fixedSize); return makeGeometry(ColumnId::MeshGeometries, vertexArray); } + case GeomType::AABB: + { + auto const vertexArray = impl_->pointBuffers_.new_array(2, true); + return makeGeometry(ColumnId::AabbGeometries, vertexArray); + } + case GeomType::GltfNodeIndex: + { + auto const vertexArray = impl_->pointBuffers_.new_array(3, true); + return makeGeometry(ColumnId::GltfNodeIndexGeometries, vertexArray); + } } raise("Unsupported geometry type."); @@ -903,6 +976,12 @@ model_ptr TileFeatureLayer::newGeometryView( uint32_t size, const model_ptr& base) { + if (geomType == GeomType::AABB || geomType == GeomType::GltfNodeIndex) { + raise("Geometry views are only supported for point-buffer-backed geometries."); + } + if (base->geomType() == GeomType::AABB || base->geomType() == GeomType::GltfNodeIndex) { + raise("Geometry views cannot reference AABB or GltfNodeIndex geometries."); + } impl_->geomViews_.emplace_back(geomType, offset, size, base->addr()); return Geometry( &impl_->geomViews_.back(), @@ -944,65 +1023,30 @@ model_ptr TileFeatureLayer::newValidityCollection(size_t initialC mpKey_); } -std::optional TileFeatureLayer::upgradedSimpleValidityAddress( - ModelNodeAddress simpleAddress) const -{ - if (simpleAddress.column() != ColumnId::SimpleValidity) { - return std::nullopt; - } - if (auto existing = impl_->upgradedSimpleValidityAddresses_.find(simpleAddress.value_); - existing != impl_->upgradedSimpleValidityAddresses_.end()) { - return existing->second; - } - return std::nullopt; -} - ModelNodeAddress TileFeatureLayer::materializeSimpleValidity( ModelNodeAddress simpleAddress, + simfil::ArrayIndex ownerMembers, + uint32_t ownerElementIndex, Validity::Direction direction) { if (simpleAddress.column() != ColumnId::SimpleValidity) { raise("Cannot materialize non-simple validity node."); } - ModelNodeAddress upgradedAddress{}; - if (auto existing = upgradedSimpleValidityAddress(simpleAddress)) { - upgradedAddress = *existing; + auto memberAddress = arrayMemberStorage().at(ownerMembers, ownerElementIndex); + if (!memberAddress) { + raise("Simple validity owner slot could not be resolved."); } - else { - impl_->validities_.emplace_back(); - upgradedAddress = ModelNodeAddress{ - ColumnId::Validities, - static_cast(impl_->validities_.size() - 1)}; - auto& upgraded = impl_->validities_.back(); - upgraded.direction_ = direction; - impl_->upgradedSimpleValidityAddresses_.emplace(simpleAddress.value_, upgradedAddress); - } - - // Rebind every simple validity address reference to the upgraded full node. - auto& members = arrayMemberStorage(); - auto rebindArrayMembers = [&](simfil::ArrayIndex arrayIndex) - { - members.iterate( - arrayIndex, - [&](ModelNodeAddress& memberAddress) - { - if (memberAddress.value_ == simpleAddress.value_) { - memberAddress = upgradedAddress; - } - }); - }; - - for (simfil::ArrayIndex arrayIndex = simfil::FirstRegularArrayIndex; - arrayIndex < static_cast(members.size()); - ++arrayIndex) { - rebindArrayMembers(arrayIndex); - } - for (simfil::ArrayIndex singletonOrdinal = 0; - singletonOrdinal < static_cast(members.singleton_handle_count()); - ++singletonOrdinal) { - rebindArrayMembers(simfil::SingletonArrayHandleMask | singletonOrdinal); + if (memberAddress->get().value_ != simpleAddress.value_) { + raise("Simple validity owner slot no longer points at the expected simple validity."); } + impl_->validities_.emplace_back(); + auto upgradedAddress = ModelNodeAddress{ + ColumnId::Validities, + static_cast(impl_->validities_.size() - 1)}; + auto& upgraded = impl_->validities_.back(); + upgraded.direction_ = direction; + memberAddress->get() = upgradedAddress; return upgradedAddress; } @@ -1102,7 +1146,8 @@ model_ptr resolveInternal(tag, TileFeatureLayer const& mod true, 0, featureData.typeIdAndLod_.typeId_, - featureData.idPartValues_}, + featureData.idPartValues_, + simfil::StringPool::Empty}, model.shared_from_this(), node.addr(), model.mpKey_); @@ -1141,6 +1186,8 @@ model_ptr resolveInternal(tag, TileFeatureLayer const& mod model.mpKey_); case TileFeatureLayer::ColumnId::ValidityPoints: return PointNode(node, &model.impl_->validities_.at(node.addr().index()), model.mpKey_); + case TileFeatureLayer::ColumnId::GeometryPointView: + return PointNode(node, model.mpKey_); default: raise("Cannot cast this node to a Point."); } @@ -1181,6 +1228,8 @@ model_ptr resolveInternal(tag, TileFeatureLayer const& model case TileFeatureLayer::ColumnId::LineGeometries: case TileFeatureLayer::ColumnId::PolygonGeometries: case TileFeatureLayer::ColumnId::MeshGeometries: + case TileFeatureLayer::ColumnId::AabbGeometries: + case TileFeatureLayer::ColumnId::GltfNodeIndexGeometries: { return Geometry( model.shared_from_this(), @@ -1225,6 +1274,36 @@ model_ptr resolveInternal(tag, TileFeature model.mpKey_); } +template<> +model_ptr resolveInternal(tag, TileFeatureLayer const& model, ModelNode const& node) +{ + if (node.addr().column() != TileFeatureLayer::ColumnId::GeometryBoundsInfoView) { + raise("Cannot cast this node to BoundsInfo."); + } + return BoundsInfoNode(node, model.mpKey_); +} + +template<> +model_ptr resolveInternal( + tag, + TileFeatureLayer const& model, + ModelNode const& node) +{ + if (node.addr().column() != TileFeatureLayer::ColumnId::GeometryBoundsPolygonCoordinatesView) { + raise("Cannot cast this node to BoundsPolygonCoordinates."); + } + return BoundsPolygonCoordinatesNode(node, model.mpKey_); +} + +template<> +model_ptr resolveInternal(tag, TileFeatureLayer const& model, ModelNode const& node) +{ + if (node.addr().column() != TileFeatureLayer::ColumnId::GeometryBoundsRingView) { + raise("Cannot cast this node to BoundsRing."); + } + return BoundsRingNode(node, model.mpKey_); +} + template<> model_ptr resolveInternal(tag, TileFeatureLayer const& model, ModelNode const& node) { @@ -1293,20 +1372,18 @@ model_ptr resolveInternal(tag, TileFeatureLayer const& model node.addr(), model.mpKey_); case TileFeatureLayer::ColumnId::SimpleValidity: { - if (auto upgraded = model.upgradedSimpleValidityAddress(node.addr())) { - return Validity( - &model.impl_->validities_[upgraded->index()], - model.shared_from_this(), - *upgraded, - model.mpKey_); - } auto const direction = static_cast(node.addr().index()); if (direction < Validity::Empty || direction > Validity::None) { raiseFmt( "Cannot cast this node to a Validity: invalid simple validity direction value {}.", node.addr().index()); } - return Validity(direction, model.shared_from_this(), node.addr(), model.mpKey_); + return Validity( + direction, + model.shared_from_this(), + node.addr(), + node.runtimeData(), + model.mpKey_); } default: raise("Cannot cast this node to a Validity."); @@ -1366,10 +1443,15 @@ tl::expected TileFeatureLayer::resolve(const ModelNode& n, case ColumnId::PointBuffersView: cb(*resolve(n)); return {}; + case ColumnId::GeometryPointView: + cb(*resolve(n)); + return {}; case ColumnId::PointGeometries: case ColumnId::LineGeometries: case ColumnId::PolygonGeometries: case ColumnId::MeshGeometries: + case ColumnId::AabbGeometries: + case ColumnId::GltfNodeIndexGeometries: case ColumnId::GeometryViews: cb(*resolve(n)); return {}; @@ -1379,6 +1461,15 @@ tl::expected TileFeatureLayer::resolve(const ModelNode& n, case ColumnId::GeometryArrayView: cb(*resolve(n)); return {}; + case ColumnId::GeometryBoundsInfoView: + cb(*resolve(n)); + return {}; + case ColumnId::GeometryBoundsPolygonCoordinatesView: + cb(*resolve(n)); + return {}; + case ColumnId::GeometryBoundsRingView: + cb(*resolve(n)); + return {}; case ColumnId::Polygon: cb(*resolve(n)); return {}; @@ -1513,28 +1604,13 @@ nlohmann::json TileFeatureLayer::toJson() const impl_->geometryAnchor_.y, impl_->geometryAnchor_.z}; - // Add ID prefix if set - if (impl_->featureIdPrefix_) { - auto prefix = const_cast(this)->getIdPrefix(); - if (prefix) - result["idPrefix"] = prefix->toJson(); + if (impl_->glbAttachment_) { + result["glbAttachment"] = impl_->glbAttachment_->toJsonMetadata(); } - // Add timestamp as ISO 8601 string - { - auto time_t_val = std::chrono::system_clock::to_time_t(timestamp_); - auto microseconds = std::chrono::duration_cast( - timestamp_.time_since_epoch()).count() % 1000000; - std::tm tm_val{}; -#ifdef _WIN32 - gmtime_s(&tm_val, &time_t_val); -#else - gmtime_r(&time_t_val, &tm_val); -#endif - char buf[32]; - std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", &tm_val); - result["timestamp"] = fmt::format("{}.{:06d}Z", buf, microseconds); - } + // Preserve the binary timestamp representation exactly in strict JSON. + result["timestamp"] = std::chrono::duration_cast( + timestamp_.time_since_epoch()).count(); // Add TTL if set (in milliseconds) if (ttl_) @@ -1580,6 +1656,11 @@ nlohmann::json TileFeatureLayer::serializationSizeStats() const featureLayer["geometry-views"] = impl_->geomViews_.byte_size(); featureLayer["point-buffers"] = impl_->pointBuffers_.byte_size(); featureLayer["source-data-references"] = impl_->sourceDataReferences_.byte_size(); + featureLayer["glb-attachment-present"] = impl_->glbAttachment_.has_value(); + featureLayer["glb-attachment-name"] = + impl_->glbAttachment_ ? impl_->glbAttachment_->name_.size() : 0; + featureLayer["glb-attachment-payload"] = + impl_->glbAttachment_ ? impl_->glbAttachment_->bytes_.size() : 0; auto singletonStatsToJson = [](auto const& stats) { return nlohmann::json::object({ @@ -1607,24 +1688,29 @@ nlohmann::json TileFeatureLayer::serializationSizeStats() const {"view", 0}, {"with-source-data-references", 0}, {"base-vertex-buffer-allocated", 0}, - {"base-vertex-buffer-unallocated", 0}, {"by-type", nlohmann::json::object({ {"points", 0}, {"line", 0}, {"polygon", 0}, {"mesh", 0}, + {"aabb", 0}, + {"gltf-node-index", 0}, })}, {"base-by-type", nlohmann::json::object({ {"points", 0}, {"line", 0}, {"polygon", 0}, {"mesh", 0}, + {"aabb", 0}, + {"gltf-node-index", 0}, })}, {"view-by-type", nlohmann::json::object({ {"points", 0}, {"line", 0}, {"polygon", 0}, {"mesh", 0}, + {"aabb", 0}, + {"gltf-node-index", 0}, })}, }); @@ -1673,6 +1759,8 @@ nlohmann::json TileFeatureLayer::serializationSizeStats() const case GeomType::Line: return "line"; case GeomType::Polygon: return "polygon"; case GeomType::Mesh: return "mesh"; + case GeomType::AABB: return "aabb"; + case GeomType::GltfNodeIndex: return "gltf-node-index"; } return "points"; }; @@ -2013,6 +2101,14 @@ TileFeatureLayer::setStrings(std::shared_ptr const& newDict) else return tl::unexpected(res.error()); } + if (fid.extMapId_ != simfil::StringPool::Empty) { + if (auto resolvedName = oldDict->resolve(fid.extMapId_)) { + if (auto res = newDict->emplace(*resolvedName)) + fid.extMapId_ = *res; + else + return tl::unexpected(res.error()); + } + } } for (auto& feature : impl_->features_) { if (auto resolvedName = oldDict->resolve(feature.typeIdAndLod_.typeId_)) { @@ -2080,18 +2176,33 @@ ModelNode::Ptr TileFeatureLayer::clone( case ColumnId::LineGeometries: case ColumnId::PolygonGeometries: case ColumnId::MeshGeometries: + case ColumnId::AabbGeometries: + case ColumnId::GltfNodeIndexGeometries: case ColumnId::GeometryViews: { // TODO: This implementation is not great, because it does not respect // Geometry views - it just converts every Geometry to a self-contained one. auto resolved = otherLayer->resolve(*otherNode); auto newNode = newGeometry(resolved->geomType(), resolved->numPoints(), true); newCacheNode = newNode; - resolved->forEachPoint( - [&newNode](auto&& pt) - { - newNode->append(pt); - return true; - }); + switch (resolved->geomType()) { + case GeomType::GltfNodeIndex: + newNode->setGltfNodeIndex(resolved->gltfNodeIndex()); + newNode->setGltfNodeBounds( + resolved->gltfNodeAabbOrigin(), + resolved->gltfNodeAabbSize()); + break; + case GeomType::AABB: + newNode->setAabb(resolved->aabbOrigin(), resolved->aabbSize()); + break; + default: + resolved->forEachPoint( + [&newNode](auto&& pt) + { + newNode->append(pt); + return true; + }); + break; + } if (auto geometryStage = resolved->stage()) { if (*geometryStage > std::numeric_limits::max()) { raiseFmt("Geometry stage {} exceeds uint8_t range during clone.", *geometryStage); @@ -2151,13 +2262,19 @@ ModelNode::Ptr TileFeatureLayer::clone( } case ColumnId::FeatureIds: { auto resolved = otherLayer->resolve(*otherNode); - auto newNode = newFeatureId(resolved->typeId(), resolved->keyValuePairs()); + auto newNode = newFeatureId( + resolved->typeId(), + resolved->keyValuePairs(), + resolved->externalMapId()); newCacheNode = newNode; break; } case ColumnId::ExternalFeatureIds: { auto resolved = otherLayer->resolve(*otherNode); - auto newNode = newFeatureId(resolved->typeId(), resolved->keyValuePairs()); + auto newNode = newFeatureId( + resolved->typeId(), + resolved->keyValuePairs(), + resolved->externalMapId()); newCacheNode = newNode; break; } @@ -2395,7 +2512,9 @@ std::optional TileFeatureLayer::geometryStage(simfil::ModelNodeAddress case ColumnId::PointGeometries: case ColumnId::LineGeometries: case ColumnId::PolygonGeometries: - case ColumnId::MeshGeometries: { + case ColumnId::MeshGeometries: + case ColumnId::AabbGeometries: + case ColumnId::GltfNodeIndexGeometries: { auto const compactIndex = extraGeometryDataStorageIndex(address.index()); if (auto storedStage = geometryStageAt(impl_->geomStages_, compactIndex)) { return storedStage; @@ -2421,7 +2540,9 @@ void TileFeatureLayer::setGeometryStage( case ColumnId::PointGeometries: case ColumnId::LineGeometries: case ColumnId::PolygonGeometries: - case ColumnId::MeshGeometries: { + case ColumnId::MeshGeometries: + case ColumnId::AabbGeometries: + case ColumnId::GltfNodeIndexGeometries: { auto const compactIndex = extraGeometryDataStorageIndex(address.index()); ensureGeometryStageCapacity(impl_->geomStages_, compactIndex); impl_->geomStages_.at(compactIndex) = stage.value_or(InvalidGeometryStage); @@ -2440,7 +2561,9 @@ simfil::ModelNodeAddress TileFeatureLayer::geometrySourceDataReferences(simfil:: case ColumnId::PointGeometries: case ColumnId::LineGeometries: case ColumnId::PolygonGeometries: - case ColumnId::MeshGeometries: { + case ColumnId::MeshGeometries: + case ColumnId::AabbGeometries: + case ColumnId::GltfNodeIndexGeometries: { auto const compactIndex = extraGeometryDataStorageIndex(address.index()); return geometrySourceRefsAt(impl_->geomSourceDataRefs_, compactIndex); } @@ -2459,7 +2582,9 @@ void TileFeatureLayer::setGeometrySourceDataReferences( case ColumnId::PointGeometries: case ColumnId::LineGeometries: case ColumnId::PolygonGeometries: - case ColumnId::MeshGeometries: { + case ColumnId::MeshGeometries: + case ColumnId::AabbGeometries: + case ColumnId::GltfNodeIndexGeometries: { auto const compactIndex = extraGeometryDataStorageIndex(address.index()); ensureGeometrySourceRefCapacity(impl_->geomSourceDataRefs_, compactIndex); impl_->geomSourceDataRefs_.at(compactIndex) = refsAddress; @@ -2475,58 +2600,11 @@ void TileFeatureLayer::setGeometrySourceDataReferences( model_ptr TileFeatureLayer::find(const std::string_view& featureId) const { - using namespace std::ranges; - auto tokensRange = featureId | views::split('.'); - auto tokens = std::vector(tokensRange.begin(), tokensRange.end()); - - if (tokens.empty()) { + ParsedFeatureId parsed; + if (!parseFeatureIdString(featureId, *layerInfo_, parsed)) { return {}; } - auto tokenAt = [&tokens](auto&& i) { - return std::string_view(&*tokens[i].begin(), distance(tokens[i])); - }; - - auto typeInfo = layerInfo_->getTypeInfo(tokenAt(0), false); - if (!typeInfo || typeInfo->uniqueIdCompositions_.empty()) - return {}; - - // Convert the part strings to key-value pairs using the first (primary) ID composition. - KeyValuePairs kvPairs; - for (auto withOptionalParts : {true, false}) { - size_t tokenIndex = 1; - bool error = false; - kvPairs.clear(); - - for (const auto& part : typeInfo->uniqueIdCompositions_[0]) { - if (part.isOptional_ && !withOptionalParts) - continue; - - if (tokenIndex >= tokens.size()) { - error = true; - break; - } - - std::variant parsedValue = std::string(tokenAt(tokenIndex++)); - if (!part.validate(parsedValue)) { - error = true; - break; - } - - kvPairs.emplace_back(part.idPartLabel_, parsedValue); - } - - if (tokenIndex < tokens.size()) { - error = true; - } - - if (error) { - if (!withOptionalParts) - return {}; - // Go on to try without optional parts. - } - } - - return find(typeInfo->name_, kvPairs); + return find(parsed.typeId_, parsed.keyValuePairs_); } } diff --git a/libs/model/src/geojson-import.cpp b/libs/model/src/geojson-import.cpp new file mode 100644 index 00000000..0140d59e --- /dev/null +++ b/libs/model/src/geojson-import.cpp @@ -0,0 +1,1085 @@ +#include "mapget/model/geojson-import.h" + +#include "mapget/model/attr.h" +#include "mapget/model/attrlayer.h" +#include "mapget/model/feature.h" +#include "mapget/model/featureid.h" +#include "mapget/model/featurelayer.h" +#include "mapget/model/geometry.h" +#include "mapget/model/relation.h" +#include "mapget/model/sourcedatareference.h" +#include "mapget/model/validity.h" +#include "mapget/log.h" +#include "simfil/model/json.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace mapget +{ + +namespace +{ +/** Raise a consistently prefixed import error. */ +[[noreturn]] void raiseImport(std::string const& message) +{ + raiseFmt("TileFeatureLayer::fromJson: {}", message); +} + +/** Parse mapget validity direction labels, including legacy aliases. */ +[[nodiscard]] std::optional directionFromJson(nlohmann::json const& json) +{ + if (!json.is_string()) { + return std::nullopt; + } + + auto const value = json.get(); + if (value == "POSITIVE") { + return Validity::Positive; + } + if (value == "NEGATIVE") { + return Validity::Negative; + } + if (value == "COMPLETE" || value == "BOTH") { + return Validity::Both; + } + if (value == "NONE") { + return Validity::None; + } + if (value == "EMPTY") { + return Validity::Empty; + } + return std::nullopt; +} + +/** Parse transition endpoint labels used by feature-transition validities. */ +[[nodiscard]] std::optional transitionEndFromJson(nlohmann::json const& json) +{ + if (!json.is_string()) { + return std::nullopt; + } + auto const value = json.get(); + if (value == "START") { + return Validity::Start; + } + if (value == "END") { + return Validity::End; + } + return std::nullopt; +} + +/** Parse the offset encoding used by position/range validities. */ +[[nodiscard]] std::optional offsetTypeFromJson(nlohmann::json const& json) +{ + if (!json.is_string()) { + return std::nullopt; + } + auto const value = json.get(); + if (value == "GeoPosOffset") { + return Validity::GeoPosOffset; + } + if (value == "BufferOffset") { + return Validity::BufferOffset; + } + if (value == "RelativeLengthOffset") { + return Validity::RelativeLengthOffset; + } + if (value == "MetricLengthOffset") { + return Validity::MetricLengthOffset; + } + return std::nullopt; +} + +/** Parse a GeoJSON position into mapget's Point type. */ +[[nodiscard]] Point pointFromCoordinateJson(nlohmann::json const& json) +{ + if (!json.is_array() || json.size() < 2 || json.size() > 3) { + raiseImport("Expected GeoJSON coordinate array with 2 or 3 numeric elements."); + } + return Point{ + json.at(0).get(), + json.at(1).get(), + json.size() >= 3 ? json.at(2).get() : 0.0}; +} + +/** Decode a JSON scalar into the storage type expected by an id-part definition. */ +[[nodiscard]] std::variant jsonToIdPartValue( + nlohmann::json const& json, + IdPart const& idPart) +{ + if (json.is_number_integer()) { + return json.get(); + } + if (json.is_number_unsigned()) { + auto const value = json.get(); + if (value > static_cast(std::numeric_limits::max())) { + raiseImport(fmt::format("Id part '{}' exceeds int64_t range.", idPart.idPartLabel_)); + } + return static_cast(value); + } + if (json.is_string()) { + return json.get(); + } + raiseImport(fmt::format( + "Id part '{}' must be encoded as an integer or string JSON value.", + idPart.idPartLabel_)); +} + +/** Validate an id-part value with the same rules used by binary feature creation. */ +void validateIdPartValue( + IdPart const& idPart, + std::variant& value) +{ + std::string error; + if (!idPart.validate(value, &error)) { + raiseImport(error); + } +} + +/** Delegate generic object/array/scalar import to simfil's shared JSON builder. */ +[[nodiscard]] simfil::ModelNode::Ptr importGenericNode( + TileFeatureLayer& tile, + nlohmann::json const& json) +{ + auto node = simfil::json::buildModelNode(json, tile); + if (!node) { + raiseImport(node.error().message); + } + return *node; +} + +/** Find either `_sourceData` or the legacy `sourceData` alias on a JSON object. */ +[[nodiscard]] nlohmann::json const* findSourceDataJson(nlohmann::json const& json) +{ + if (!json.is_object()) { + return nullptr; + } + if (auto it = json.find("_sourceData"); it != json.end()) { + return &(*it); + } + if (auto it = json.find("sourceData"); it != json.end()) { + return &(*it); + } + return nullptr; +} + +/** Resolve a geometry stage label back to its layer-specific numeric stage. */ +[[nodiscard]] std::optional stageFromGeometryName( + TileFeatureLayer const& tile, + nlohmann::json const& json, + bool strict) +{ + auto const* layerInfo = tile.layerInfo().get(); + if (!layerInfo) { + return std::nullopt; + } + if (!json.contains("geometryName")) { + return layerInfo->highFidelityStage_; + } + if (!json.at("geometryName").is_string()) { + raiseImport("geometryName must be a string."); + } + auto const label = json.at("geometryName").get(); + auto const& labels = layerInfo->stageLabels_; + std::optional matchedStage; + for (uint32_t i = 0; i < labels.size(); ++i) { + if (labels[i] != label) { + continue; + } + if (matchedStage) { + if (strict) { + raiseImport(fmt::format( + "Ambiguous geometryName '{}' for layer '{}'.", + label, + layerInfo->layerId_)); + } + // In best-effort mode ambiguous labels are ignored rather than guessed. + return std::nullopt; + } + matchedStage = i; + } + if (!matchedStage) { + if (strict) { + raiseImport(fmt::format( + "Unknown geometryName '{}' for layer '{}'.", + label, + layerInfo->layerId_)); + } + return std::nullopt; + } + return matchedStage; +} + +/** Restrict best-effort synthetic ids to integer id-part slots. */ +[[nodiscard]] bool isIntegerIdPart(IdPartDataType datatype) +{ + switch (datatype) { + case IdPartDataType::I32: + case IdPartDataType::U32: + case IdPartDataType::I64: + case IdPartDataType::U64: + return true; + case IdPartDataType::UUID128: + case IdPartDataType::STR: + return false; + } + return false; +} + +/** + * Synthesize full feature-id parts for permissive GeoJSON import. + * + * The tile id is injected when the composition contains a `tileId` part, and + * the first remaining integer slot is filled with the feature's collection index. + */ +[[nodiscard]] KeyValuePairs bestEffortFullFeatureIdParts( + TileFeatureLayer const& tile, + std::string_view typeId, + uint32_t fallbackFeatureIndex) +{ + auto const* typeInfo = tile.layerInfo()->getTypeInfo(typeId, false); + if (!typeInfo || typeInfo->uniqueIdCompositions_.empty()) { + raiseImport(fmt::format( + "Could not resolve feature type '{}' for best-effort import.", + typeId)); + } + + if (tile.tileId().value_ > static_cast(std::numeric_limits::max())) { + raiseImport("tileId exceeds signed integer range needed for best-effort id synthesis."); + } + + auto const& composition = typeInfo->uniqueIdCompositions_.front(); + KeyValuePairs parts; + parts.reserve(composition.size()); + bool usedFeatureIndex = false; + + for (auto const& idPart : composition) { + if (idPart.idPartLabel_ == "tileId") { + if (!isIntegerIdPart(idPart.datatype_)) { + raiseImport("Best-effort GeoJSON import requires an integer tileId id part."); + } + parts.emplace_back(idPart.idPartLabel_, static_cast(tile.tileId().value_)); + continue; + } + + if (!usedFeatureIndex && isIntegerIdPart(idPart.datatype_)) { + // Best-effort mode needs one deterministic per-feature slot to keep ids stable. + parts.emplace_back(idPart.idPartLabel_, static_cast(fallbackFeatureIndex)); + usedFeatureIndex = true; + continue; + } + + if (idPart.isOptional_) { + // Optional suffix parts may be dropped because no source field exists to recover them. + continue; + } + + raiseImport(fmt::format( + "Could not synthesize best-effort feature ids for type '{}'. " + "Provide explicit id fields for required part '{}'.", + typeId, + idPart.idPartLabel_)); + } + + if (!usedFeatureIndex) { + raiseImport(fmt::format( + "Could not synthesize best-effort feature ids for type '{}'. " + "The primary id composition has no usable per-feature integer part.", + typeId)); + } + + return parts; +} + +/** Read all required id fields for strict import from the feature JSON object. */ +[[nodiscard]] KeyValuePairs fullFeatureIdPartsFromFields( + nlohmann::json const& featureJson, + std::vector const& composition) +{ + KeyValuePairs result; + result.reserve(composition.size()); + + for (auto const& idPart : composition) { + auto it = featureJson.find(idPart.idPartLabel_); + if (it == featureJson.end()) { + if (idPart.isOptional_) { + continue; + } + raiseImport(fmt::format( + "Feature is missing non-optional id field '{}' required by the primary composition.", + idPart.idPartLabel_)); + } + auto value = jsonToIdPartValue(*it, idPart); + validateIdPartValue(idPart, value); + result.emplace_back(idPart.idPartLabel_, std::move(value)); + } + + return result; +} + +/** Import source-data references from the JSON representation used by toJson(). */ +[[nodiscard]] std::optional> importSourceDataReferences( + TileFeatureLayer& tile, + nlohmann::json const& json) +{ + if (json.is_null()) { + return std::nullopt; + } + if (!json.is_array()) { + raiseImport("sourceData must be encoded as an array."); + } + + std::vector refs; + refs.reserve(json.size()); + for (auto const& item : json) { + if (!item.is_object()) { + raiseImport("Every sourceData entry must be an object."); + } + if (!item.contains("address") || !item.contains("layerId") || !item.contains("qualifier")) { + raiseImport("Every sourceData entry must contain address, layerId, and qualifier."); + } + + uint64_t address = 0; + if (item.at("address").is_number_unsigned()) { + address = item.at("address").get(); + } + else if (item.at("address").is_number_integer()) { + auto const signedAddress = item.at("address").get(); + if (signedAddress < 0) { + raiseImport("sourceData address must not be negative."); + } + address = static_cast(signedAddress); + } + else { + raiseImport("sourceData address must be an integer."); + } + + auto layerId = item.at("layerId").get(); + auto qualifier = item.at("qualifier").get(); + auto layerIdId = tile.strings()->emplace(layerId); + if (!layerIdId) { + raiseImport(layerIdId.error().message); + } + auto qualifierId = tile.strings()->emplace(qualifier); + if (!qualifierId) { + raiseImport(qualifierId.error().message); + } + + refs.push_back(QualifiedSourceDataReference{ + SourceDataAddress{address}, + *layerIdId, + *qualifierId, + }); + } + + return tile.newSourceDataReferenceCollection(refs); +} + +struct ParsedFeatureReferenceJson +{ + ParsedFeatureId featureId_; + std::optional externalMapId_; +}; + +/** Parse a local or external feature reference from JSON. */ +[[nodiscard]] ParsedFeatureReferenceJson parseFeatureReferenceJson( + TileFeatureLayer& tile, + nlohmann::json const& json) +{ + std::string canonicalId; + std::optional externalMapId; + + if (json.is_string()) { + canonicalId = json.get(); + } + else if (json.is_object()) { + auto const idIt = json.find("id"); + if (idIt == json.end() || !idIt->is_string()) { + raiseImport("Feature reference objects must contain a string `id` field."); + } + canonicalId = idIt->get(); + + if (auto mapIdIt = json.find("mapId"); mapIdIt != json.end()) { + if (!mapIdIt->is_string()) { + raiseImport("Feature reference `mapId` must be a string."); + } + externalMapId = mapIdIt->get(); + } + } + else { + raiseImport("Feature references must be encoded as canonical feature-id strings or `{id, mapId}` objects."); + } + + ParsedFeatureReferenceJson parsed; + std::string error; + if (!parseFeatureIdString(canonicalId, *tile.layerInfo(), parsed.featureId_, &error)) { + raiseImport(error); + } + parsed.externalMapId_ = std::move(externalMapId); + return parsed; +} + +/** Parse a canonical feature-id string into a detached FeatureId node. */ +[[nodiscard]] model_ptr importFeatureReferenceId( + TileFeatureLayer& tile, + nlohmann::json const& json) +{ + auto parsed = parseFeatureReferenceJson(tile, json); + auto partsView = castToKeyValueView(parsed.featureId_.keyValuePairs_); + auto externalMapId = parsed.externalMapId_ + ? std::optional(*parsed.externalMapId_) + : std::nullopt; + return tile.newFeatureId(parsed.featureId_.typeId_, partsView, externalMapId); +} + +/** Resolve a canonical feature-id string to a feature already created in this tile. */ +[[nodiscard]] model_ptr resolveLocalFeatureReference( + TileFeatureLayer& tile, + nlohmann::json const& json) +{ + auto parsed = parseFeatureReferenceJson(tile, json); + if (parsed.externalMapId_ && *parsed.externalMapId_ != tile.mapId()) { + raiseImport(fmt::format( + "Feature transition references must stay local to map '{}'.", + tile.mapId())); + } + + if (auto feature = tile.find(parsed.featureId_.typeId_, parsed.featureId_.keyValuePairs_)) { + return feature; + } + auto referenceJson = json.is_string() ? json.get() : json.dump(); + raiseImport(fmt::format("Could not resolve local feature reference '{}'.", referenceJson)); + return {}; +} + +[[nodiscard]] model_ptr importStandaloneGeometry( + TileFeatureLayer& tile, + nlohmann::json const& geometryJson, + GeoJsonImportOptions const& options); + +/** Apply metadata that is shared by all geometry encodings. */ +void applyGeometryDecorations( + TileFeatureLayer& tile, + model_ptr geometry, + nlohmann::json const& geometryJson, + GeoJsonImportOptions const& options) +{ + if (!geometry) { + return; + } + + if (auto stage = stageFromGeometryName(tile, geometryJson, options.strict_)) { + geometry->setStage(*stage); + } + if (auto sourceDataJson = findSourceDataJson(geometryJson)) { + if (auto refs = importSourceDataReferences(tile, *sourceDataJson)) { + geometry->setSourceDataReferences(*refs); + } + } +} + +/** Import one standalone geometry object outside of feature-collection splitting logic. */ +[[nodiscard]] model_ptr importStandaloneGeometry( + TileFeatureLayer& tile, + nlohmann::json const& geometryJson, + GeoJsonImportOptions const& options) +{ + if (!geometryJson.is_object()) { + raiseImport("Geometry must be a JSON object."); + } + if (!geometryJson.contains("type") || !geometryJson.at("type").is_string()) { + raiseImport("Geometry object is missing string field 'type'."); + } + + auto const type = geometryJson.at("type").get(); + + if (type == "Point") { + auto geometry = tile.newGeometry(GeomType::Points, 1, true); + geometry->append(pointFromCoordinateJson(geometryJson.at("coordinates"))); + applyGeometryDecorations(tile, geometry, geometryJson, options); + return geometry; + } + + if (type == "MultiPoint") { + auto const& coords = geometryJson.at("coordinates"); + auto geometry = tile.newGeometry(GeomType::Points, coords.size(), true); + for (auto const& coord : coords) { + geometry->append(pointFromCoordinateJson(coord)); + } + applyGeometryDecorations(tile, geometry, geometryJson, options); + return geometry; + } + + if (type == "LineString") { + auto const& coords = geometryJson.at("coordinates"); + auto geometry = tile.newGeometry(GeomType::Line, coords.size(), true); + for (auto const& coord : coords) { + geometry->append(pointFromCoordinateJson(coord)); + } + applyGeometryDecorations(tile, geometry, geometryJson, options); + return geometry; + } + + if (type == "MultiLineString") { + raiseImport("Standalone validity geometries do not support MultiLineString values."); + } + + if (type == "Polygon") { + if (geometryJson.contains("gltfNodeIndex")) { + raiseImport("GLTF-backed geometry import is not supported yet."); + } + if (geometryJson.contains("aabb")) { + auto const& aabb = geometryJson.at("aabb"); + auto geometry = tile.newGeometry(GeomType::AABB, 2, true); + geometry->setAabb( + pointFromCoordinateJson(aabb.at("origin")), + pointFromCoordinateJson(aabb.at("size"))); + applyGeometryDecorations(tile, geometry, geometryJson, options); + return geometry; + } + + auto const& coords = geometryJson.at("coordinates"); + if (!coords.is_array() || coords.empty()) { + raiseImport("Polygon coordinates must contain at least one linear ring."); + } + if (coords.size() > 1 && options.strict_) { + // Native mapget polygons do not preserve hole rings, so strict mode rejects them. + raiseImport("Polygon holes are not supported by mapget polygon geometries."); + } + auto const& outerRing = coords.at(0); + auto geometry = tile.newGeometry(GeomType::Polygon, outerRing.size(), true); + for (auto const& coord : outerRing) { + geometry->append(pointFromCoordinateJson(coord)); + } + applyGeometryDecorations(tile, geometry, geometryJson, options); + return geometry; + } + + if (type == "MultiPolygon") { + auto const& polygons = geometryJson.at("coordinates"); + if (!options.strict_) { + raiseImport("Standalone generic MultiPolygon import requires a feature context."); + } + auto geometry = tile.newGeometry(GeomType::Mesh, polygons.size() * 3, true); + for (auto const& polygon : polygons) { + if (!polygon.is_array() || polygon.size() != 1 || !polygon.at(0).is_array()) { + raiseImport("Strict mapget mesh import expects every MultiPolygon item to contain exactly one ring."); + } + auto const& ring = polygon.at(0); + std::vector triangle; + triangle.reserve(ring.size()); + for (auto const& coord : ring) { + triangle.push_back(pointFromCoordinateJson(coord)); + } + if (!triangle.empty() && triangle.front() == triangle.back()) { + // GeoJSON triangle rings are closed, while mapget mesh triangles are stored open. + triangle.pop_back(); + } + if (triangle.size() != 3) { + raiseImport("Strict mapget mesh import expects triangular MultiPolygon rings."); + } + for (auto const& point : triangle) { + geometry->append(point); + } + } + applyGeometryDecorations(tile, geometry, geometryJson, options); + return geometry; + } + + if (type == "GeometryCollection") { + raiseImport("Standalone validity geometries do not support GeometryCollection values."); + } + + raiseImport(fmt::format("Unsupported geometry type '{}'.", type)); + return {}; +} + +/** Import feature geometry, expanding GeoJSON aggregate types into mapget's geometry list. */ +void importFeatureGeometry( + TileFeatureLayer& tile, + model_ptr feature, + nlohmann::json const& geometryJson, + GeoJsonImportOptions const& options) +{ + if (geometryJson.is_null()) { + return; + } + if (!geometryJson.is_object()) { + raiseImport("Feature geometry must be an object or null."); + } + + if (!geometryJson.contains("type") || !geometryJson.at("type").is_string()) { + raiseImport("Feature geometry must contain a string field 'type'."); + } + auto const type = geometryJson.at("type").get(); + if (type == "GeometryCollection") { + if (!geometryJson.contains("geometries") || !geometryJson.at("geometries").is_array()) { + raiseImport("GeometryCollection is missing array-valued field 'geometries'."); + } + for (auto const& child : geometryJson.at("geometries")) { + auto geometry = importStandaloneGeometry(tile, child, GeoJsonImportOptions{options.strict_, options.fallbackFeatureType_, options.objectPropertiesAsAttributeLayers_}); + feature->addGeometry(geometry); + } + return; + } + + if (type == "MultiLineString") { + auto const& lines = geometryJson.at("coordinates"); + for (auto const& line : lines) { + // mapget represents multi-lines as multiple line geometries on one feature. + auto lineJson = nlohmann::json::object({ + {"type", "LineString"}, + {"coordinates", line}, + }); + if (geometryJson.contains("geometryName")) { + lineJson["geometryName"] = geometryJson.at("geometryName"); + } + if (auto sourceDataJson = findSourceDataJson(geometryJson)) { + lineJson["_sourceData"] = *sourceDataJson; + } + auto geometry = importStandaloneGeometry(tile, lineJson, options); + feature->addGeometry(geometry); + } + return; + } + + if (type == "MultiPolygon" && !options.strict_) { + auto const& polygons = geometryJson.at("coordinates"); + for (auto const& polygon : polygons) { + if (!polygon.is_array() || polygon.empty() || !polygon.at(0).is_array()) { + raiseImport("Generic MultiPolygon import expects every polygon to contain at least one ring."); + } + // Best-effort mode degrades each polygon to its outer ring because mapget polygons have no holes. + auto polyJson = nlohmann::json::object({ + {"type", "Polygon"}, + {"coordinates", nlohmann::json::array({polygon.at(0)})}, + }); + if (auto stage = stageFromGeometryName(tile, geometryJson, options.strict_)) { + polyJson["geometryName"] = geometryJson.at("geometryName"); + } + if (auto sourceDataJson = findSourceDataJson(geometryJson)) { + polyJson["_sourceData"] = *sourceDataJson; + } + auto geometry = importStandaloneGeometry(tile, polyJson, options); + feature->addGeometry(geometry); + } + return; + } + + auto geometry = importStandaloneGeometry(tile, geometryJson, options); + feature->addGeometry(geometry); +} + +/** Import one validity or validity list into mapget's MultiValidity container. */ +void importValidityCollection( + TileFeatureLayer& tile, + model_ptr hostFeature, + model_ptr collection, + nlohmann::json const& json, + GeoJsonImportOptions const& options) +{ + auto values = json.is_array() ? json : nlohmann::json::array({json}); + for (auto const& value : values) { + if (!value.is_object()) { + raiseImport("Validity entries must be JSON objects."); + } + + auto validity = tile.newValidity(); + if (value.contains("direction")) { + auto direction = directionFromJson(value.at("direction")); + if (!direction) { + raiseImport(fmt::format("Unknown validity direction '{}'.", value.at("direction").dump())); + } + validity->setDirection(*direction); + } + if (auto stage = stageFromGeometryName(tile, value, options.strict_)) { + validity->setGeometryStage(*stage); + } + + if (value.contains("from") || value.contains("to")) { + // Transition validities are the only variant that references two local features. + if (!value.contains("from") || !value.contains("to") || + !value.contains("fromConnectedEnd") || !value.contains("toConnectedEnd") || + !value.contains("transitionNumber")) { + raiseImport("Feature transition validities must define from/to ids, connected ends, and transitionNumber."); + } + auto fromFeature = resolveLocalFeatureReference(tile, value.at("from")); + auto toFeature = resolveLocalFeatureReference(tile, value.at("to")); + auto fromEnd = transitionEndFromJson(value.at("fromConnectedEnd")); + auto toEnd = transitionEndFromJson(value.at("toConnectedEnd")); + if (!fromEnd || !toEnd) { + raiseImport("Invalid transition end in feature transition validity."); + } + validity->setFeatureTransition( + fromFeature, + *fromEnd, + toFeature, + *toEnd, + value.at("transitionNumber").get()); + collection->append(validity); + continue; + } + + if (value.contains("geometry")) { + validity->setSimpleGeometry(importStandaloneGeometry(tile, value.at("geometry"), options)); + collection->append(validity); + continue; + } + + if (value.contains("featureId")) { + validity->setFeatureId(importFeatureReferenceId(tile, value.at("featureId"))); + } + + auto offsetType = value.contains("offsetType") ? offsetTypeFromJson(value.at("offsetType")) : std::optional{}; + if (value.contains("offsetType") && !offsetType) { + raiseImport(fmt::format("Unknown validity offsetType '{}'.", value.at("offsetType").dump())); + } + + if (value.contains("point")) { + if (offsetType && *offsetType != Validity::GeoPosOffset) { + // Non-geospatial offsets reuse the `point` field name for historic JSON compatibility. + validity->setOffsetPoint(*offsetType, value.at("point").get()); + } + else { + validity->setOffsetPoint(pointFromCoordinateJson(value.at("point"))); + } + collection->append(validity); + continue; + } + + if (value.contains("start") || value.contains("end")) { + if (!value.contains("start") || !value.contains("end")) { + raiseImport("Validity ranges must define both start and end."); + } + if (offsetType && *offsetType != Validity::GeoPosOffset) { + validity->setOffsetRange( + *offsetType, + value.at("start").get(), + value.at("end").get()); + } + else { + validity->setOffsetRange( + pointFromCoordinateJson(value.at("start")), + pointFromCoordinateJson(value.at("end"))); + } + collection->append(validity); + continue; + } + + collection->append(validity); + } +} + +struct DeferredAttributeValidity +{ + model_ptr hostFeature_; + model_ptr attribute_; + nlohmann::json validityJson_; +}; + +/** Defer relation import until every feature id in the tile has been created. */ +struct DeferredRelation +{ + model_ptr feature_; + nlohmann::json relationJson_; +}; + +/** Import one attribute payload object, excluding deferred validity handling. */ +void importAttributeObject( + TileFeatureLayer& tile, + model_ptr hostFeature, + model_ptr attribute, + nlohmann::json const& json, + GeoJsonImportOptions const& options, + std::vector& deferredValidities) +{ + if (!json.is_object()) { + raiseImport(fmt::format("Attribute '{}' must be encoded as an object.", attribute->name())); + } + + for (auto const& [key, value] : json.items()) { + if (key == "validity") { + deferredValidities.push_back(DeferredAttributeValidity{hostFeature, attribute, value}); + continue; + } + if (key == "_sourceData" || key == "sourceData") { + if (auto refs = importSourceDataReferences(tile, value)) { + attribute->setSourceDataReferences(*refs); + } + continue; + } + auto result = attribute->addField(key, importGenericNode(tile, value)); + if (!result) { + raiseImport(result.error().message); + } + } +} + +/** Import feature properties and map attribute-layer payloads into their model containers. */ +void importProperties( + TileFeatureLayer& tile, + model_ptr feature, + nlohmann::json const& propertiesJson, + GeoJsonImportOptions const& options, + std::vector& deferredValidities) +{ + if (!propertiesJson.is_object()) { + raiseImport("Feature properties must be encoded as an object."); + } + + if (auto layerIt = propertiesJson.find("layer"); layerIt != propertiesJson.end()) { + if (!layerIt->is_object()) { + raiseImport("properties.layer must be a JSON object."); + } + for (auto const& [layerName, layerValue] : layerIt->items()) { + if (!layerValue.is_object()) { + raiseImport(fmt::format("Attribute layer '{}' must be a JSON object.", layerName)); + } + auto attrLayer = feature->attributeLayers()->newLayer(layerName); + for (auto const& [attrName, attrValue] : layerValue.items()) { + auto attribute = attrLayer->newAttribute(attrName); + importAttributeObject(tile, feature, attribute, attrValue, options, deferredValidities); + } + } + } + + for (auto const& [key, value] : propertiesJson.items()) { + if (key == "layer") { + // `properties.layer` is reserved for exported attribute layers. + continue; + } + if (!options.strict_ && options.objectPropertiesAsAttributeLayers_ && value.is_object()) { + // Best-effort mode may reinterpret nested objects as attribute layers for plain GeoJSON. + auto attrLayer = feature->attributeLayers()->newLayer(key); + for (auto const& [attrName, attrValue] : value.items()) { + auto attribute = attrLayer->newAttribute(attrName); + if (attrValue.is_object()) { + importAttributeObject(tile, feature, attribute, attrValue, options, deferredValidities); + } + else { + auto result = attribute->addField(attrName, importGenericNode(tile, attrValue)); + if (!result) { + raiseImport(result.error().message); + } + } + } + continue; + } + + auto result = feature->attributes()->addField(key, importGenericNode(tile, value)); + if (!result) { + raiseImport(result.error().message); + } + } +} + +/** Import one relation object after feature ids are already resolvable. */ +void importRelation( + TileFeatureLayer& tile, + model_ptr feature, + nlohmann::json const& relationJson, + GeoJsonImportOptions const& options) +{ + if (!relationJson.is_object()) { + raiseImport("Every relation must be encoded as an object."); + } + if (!relationJson.contains("name") || !relationJson.contains("target")) { + raiseImport("Every relation must contain name and target fields."); + } + + auto relation = tile.newRelation( + relationJson.at("name").get(), + importFeatureReferenceId(tile, relationJson.at("target"))); + + if (auto sourceDataJson = findSourceDataJson(relationJson)) { + if (auto refs = importSourceDataReferences(tile, *sourceDataJson)) { + relation->setSourceDataReferences(*refs); + } + } + if (relationJson.contains("sourceValidity")) { + importValidityCollection(tile, feature, relation->sourceValidity(), relationJson.at("sourceValidity"), options); + } + if (relationJson.contains("targetValidity")) { + importValidityCollection(tile, feature, relation->targetValidity(), relationJson.at("targetValidity"), options); + } + + feature->addRelation(relation); +} + +/** Determine the target feature type for one imported feature. */ +[[nodiscard]] std::string determineFeatureType( + TileFeatureLayer const& tile, + nlohmann::json const& featureJson, + GeoJsonImportOptions const& options) +{ + if (auto typeIt = featureJson.find("typeId"); typeIt != featureJson.end() && typeIt->is_string()) { + return typeIt->get(); + } + if (options.fallbackFeatureType_) { + return *options.fallbackFeatureType_; + } + if (tile.layerInfo()->featureTypes_.size() == 1) { + return tile.layerInfo()->featureTypes_.front().name_; + } + raiseImport("Could not determine feature type. Missing 'typeId' and no fallback feature type was configured."); + return {}; +} + +} + +/** Import a FeatureCollection into an empty TileFeatureLayer. */ +void importGeoJson( + TileFeatureLayer& tile, + nlohmann::json const& geoJson, + GeoJsonImportOptions const& options) +{ + if (tile.numRoots() != 0) { + raiseImport("Import requires an empty TileFeatureLayer instance."); + } + if (tile.getIdPrefix()) { + // GeoJSON import now treats full ids as the canonical representation. + raiseImport("Import requires a TileFeatureLayer without a preconfigured idPrefix."); + } + if (!geoJson.is_object() || geoJson.value("type", "") != "FeatureCollection") { + raiseImport("GeoJSON root must be a FeatureCollection."); + } + if (!geoJson.contains("features") || !geoJson.at("features").is_array()) { + raiseImport("GeoJSON FeatureCollection is missing an array-valued 'features' field."); + } + + if (options.strict_) { + if (geoJson.contains("glbAttachment")) { + raiseImport("GLB attachment import is not supported yet."); + } + // Strict mode treats top-level metadata mismatches as caller/configuration errors. + if (geoJson.contains("mapgetTileId") && geoJson.at("mapgetTileId").get() != tile.tileId().value_) { + raiseImport("mapgetTileId does not match the target tile."); + } + if (geoJson.contains("mapId") && geoJson.at("mapId").get() != tile.mapId()) { + raiseImport("mapId does not match the target tile."); + } + if (geoJson.contains("mapgetLayerId") && geoJson.at("mapgetLayerId").get() != tile.layerInfo()->layerId_) { + raiseImport("mapgetLayerId does not match the target tile layer."); + } + } + + if (geoJson.contains("geometryAnchor")) { + tile.setGeometryAnchor(pointFromCoordinateJson(geoJson.at("geometryAnchor"))); + } + if (geoJson.contains("timestamp")) { + auto const& timestampJson = geoJson.at("timestamp"); + if (!timestampJson.is_number_integer()) { + raiseImport("timestamp must be encoded as integer microseconds since the Unix epoch."); + } + tile.setTimestamp(std::chrono::time_point( + std::chrono::microseconds(timestampJson.get()))); + } + if (geoJson.contains("ttl")) { + tile.setTtl(std::chrono::milliseconds(geoJson.at("ttl").get())); + } + if (geoJson.contains("error")) { + auto const& errorJson = geoJson.at("error"); + if (!errorJson.is_object()) { + raiseImport("error must be encoded as an object."); + } + if (errorJson.contains("message")) { + tile.setError(errorJson.at("message").get()); + } + if (errorJson.contains("code")) { + tile.setErrorCode(errorJson.at("code").get()); + } + } + + std::vector deferredValidities; + std::vector deferredRelations; + + uint32_t fallbackFeatureIndex = 0; + for (auto const& featureJson : geoJson.at("features")) { + if (!featureJson.is_object()) { + raiseImport("Every feature entry must be a JSON object."); + } + if (featureJson.value("type", "") != "Feature") { + raiseImport("Every feature entry must have type 'Feature'."); + } + + auto const typeId = determineFeatureType(tile, featureJson, options); + KeyValuePairs fullIdParts; + + if (options.strict_ || featureJson.contains("typeId")) { + auto const* typeInfo = tile.layerInfo()->getTypeInfo(typeId, false); + if (!typeInfo || typeInfo->uniqueIdCompositions_.empty()) { + raiseImport(fmt::format("Could not resolve feature type '{}' for import.", typeId)); + } + fullIdParts = fullFeatureIdPartsFromFields(featureJson, typeInfo->uniqueIdCompositions_.front()); + if (featureJson.contains("id")) { + ParsedFeatureId parsed; + std::string error; + if (!parseFeatureIdString(featureJson.at("id").get(), *tile.layerInfo(), parsed, &error)) { + raiseImport(error); + } + if (parsed.typeId_ != typeId || parsed.keyValuePairs_ != fullIdParts) { + raiseImport(fmt::format("Feature id '{}' does not match explicit id-part fields.", featureJson.at("id").get())); + } + } + // Strict import reconstructs the full id verbatim instead of reintroducing tile prefixes. + } + else { + fullIdParts = bestEffortFullFeatureIdParts(tile, typeId, fallbackFeatureIndex); + } + ++fallbackFeatureIndex; + + // Keep the owned strings alive while the temporary view is handed to newFeature(). + auto feature = tile.newFeature(typeId, castToKeyValueView(fullIdParts)); + + if (auto sourceDataJson = findSourceDataJson(featureJson)) { + if (auto refs = importSourceDataReferences(tile, *sourceDataJson)) { + feature->setSourceDataReferences(*refs); + } + } + if (featureJson.contains("geometry")) { + importFeatureGeometry(tile, feature, featureJson.at("geometry"), options); + } + if (featureJson.contains("properties")) { + importProperties(tile, feature, featureJson.at("properties"), options, deferredValidities); + } + if (featureJson.contains("relations")) { + deferredRelations.push_back(DeferredRelation{feature, featureJson.at("relations")}); + } + } + + // Attribute validities may reference local features, so resolve them after all features exist. + for (auto& deferred : deferredValidities) { + importValidityCollection( + tile, + deferred.hostFeature_, + deferred.attribute_->validity(), + deferred.validityJson_, + options); + } + + // Relations are imported last for the same reason: all target ids must already be present. + for (auto& deferred : deferredRelations) { + if (!deferred.relationJson_.is_array()) { + raiseImport("Feature relations must be encoded as an array."); + } + for (auto const& relationJson : deferred.relationJson_) { + importRelation(tile, deferred.feature_, relationJson, options); + } + } +} + +void TileFeatureLayer::fromJson(nlohmann::json const& json, GeoJsonImportOptions const& options) +{ + importGeoJson(*this, json, options); +} + +} diff --git a/libs/model/src/geometry.cpp b/libs/model/src/geometry.cpp index 350cef3a..6652bac7 100644 --- a/libs/model/src/geometry.cpp +++ b/libs/model/src/geometry.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,7 @@ static const std::string_view MultiPolygonStr("MultiPolygon"); namespace { +/** Project a point onto a finite line segment in the x/y plane. */ std::optional projectPointOnLine(const glm::dvec3& point, const glm::dvec3& a, const glm::dvec3& b) { @@ -64,15 +66,19 @@ using namespace simfil; namespace { +/** Check whether a model column stores an actual geometry payload. */ bool isBaseGeometryColumn(uint8_t column) { using Col = TileFeatureLayer::ColumnId; return column == Col::PointGeometries || column == Col::LineGeometries || column == Col::PolygonGeometries || - column == Col::MeshGeometries; + column == Col::MeshGeometries || + column == Col::AabbGeometries || + column == Col::GltfNodeIndexGeometries; } +/** Map storage columns back to their logical geometry type. */ GeomType geometryTypeForColumn(uint8_t column) { using Col = TileFeatureLayer::ColumnId; @@ -85,12 +91,17 @@ GeomType geometryTypeForColumn(uint8_t column) return GeomType::Polygon; case Col::MeshGeometries: return GeomType::Mesh; + case Col::AabbGeometries: + return GeomType::AABB; + case Col::GltfNodeIndexGeometries: + return GeomType::GltfNodeIndex; default: raiseFmt("Unexpected geometry column {}.", column); return GeomType::Points; } } +/** Map a stored geometry stage back to the optional exported `geometryName`. */ std::optional geometryNameForStage( TileFeatureLayer const& model, std::optional geometryStage) @@ -100,6 +111,8 @@ std::optional geometryNameForStage( } auto const& layerInfo = *model.layerInfo(); if (*geometryStage <= layerInfo.highFidelityStage_) { + // The default high-fidelity stage is represented by the absence of a + // label so generic GeoJSON stays uncluttered. return std::nullopt; } if (*geometryStage >= layerInfo.stageLabels_.size()) { @@ -112,6 +125,146 @@ std::optional geometryNameForStage( return label; } +/** Serialize one 3D coordinate triple in GeoJSON position form. */ +nlohmann::json positionJson(Point const& point) +{ + return nlohmann::json::array({point.x, point.y, point.z}); +} + +/** Build the footprint polygon used to export AABB-like geometries. */ +nlohmann::json groundFootprintPolygon(Point const& origin, Point const& size) +{ + auto const minX = origin.x; + auto const minY = origin.y; + auto const minZ = origin.z; + auto const maxX = origin.x + size.x; + auto const maxY = origin.y + size.y; + + return nlohmann::json::array({ + nlohmann::json::array({ + nlohmann::json::array({minX, minY, minZ}), + nlohmann::json::array({maxX, minY, minZ}), + nlohmann::json::array({maxX, maxY, minZ}), + nlohmann::json::array({minX, maxY, minZ}), + nlohmann::json::array({minX, minY, minZ}), + }) + }); +} + +/** Resolve the origin of an AABB-like geometry regardless of storage flavor. */ +Point boundsOrigin(model_ptr const& geometry) +{ + return geometry->geomType() == GeomType::AABB + ? geometry->aabbOrigin() + : geometry->gltfNodeAabbOrigin(); +} + +/** Resolve the size of an AABB-like geometry regardless of storage flavor. */ +Point boundsSize(model_ptr const& geometry) +{ + return geometry->geomType() == GeomType::AABB + ? geometry->aabbSize() + : geometry->gltfNodeAabbSize(); +} + +/** Reinterpret a base geometry address in one of the helper-view columns. */ +ModelNodeAddress geometryHelperAddress( + uint8_t helperColumn, + ModelNodeAddress baseGeometryAddress) +{ + return {helperColumn, baseGeometryAddress.index()}; +} + +/** Encode the base geometry address into the helper node payload. */ +int64_t geometryHelperData(ModelNodeAddress baseGeometryAddress) +{ + return encodeGeometryHelperData(baseGeometryAddress); +} + +/** Encode base address plus point-view flavor for helper point nodes. */ +int64_t geometryPointHelperData( + ModelNodeAddress baseGeometryAddress, + GeometryPointViewKind pointKind) +{ + return encodeGeometryHelperData(baseGeometryAddress, static_cast(pointKind)); +} + +/** Create the object view that exposes origin/size for bounds geometries. */ +ModelNode::Ptr makeBoundsInfoView( + TileFeatureLayer const& model, + ModelNodeAddress baseGeometryAddress) +{ + return model.resolve( + geometryHelperAddress( + TileFeatureLayer::ColumnId::GeometryBoundsInfoView, + baseGeometryAddress), + geometryHelperData(baseGeometryAddress)); +} + +/** Create the polygon coordinate view used for AABB and GLTF-bounds export. */ +ModelNode::Ptr makeBoundsPolygonCoordinatesView( + TileFeatureLayer const& model, + ModelNodeAddress baseGeometryAddress) +{ + return model.resolve( + geometryHelperAddress( + TileFeatureLayer::ColumnId::GeometryBoundsPolygonCoordinatesView, + baseGeometryAddress), + geometryHelperData(baseGeometryAddress)); +} + +/** Create the single ring used by the bounds polygon coordinate view. */ +ModelNode::Ptr makeBoundsRingView( + TileFeatureLayer const& model, + ModelNodeAddress baseGeometryAddress) +{ + return model.resolve( + geometryHelperAddress( + TileFeatureLayer::ColumnId::GeometryBoundsRingView, + baseGeometryAddress), + geometryHelperData(baseGeometryAddress)); +} + +/** Create a procedural point view into either a bounds helper or point buffer. */ +ModelNode::Ptr makeGeometryPointView( + TileFeatureLayer const& model, + ModelNodeAddress baseGeometryAddress, + GeometryPointViewKind pointKind) +{ + return model.resolve( + geometryHelperAddress( + TileFeatureLayer::ColumnId::GeometryPointView, + baseGeometryAddress), + geometryPointHelperData(baseGeometryAddress, pointKind)); +} + +constexpr size_t GltfNodeIndexSlot = 0; +constexpr size_t GltfNodeAabbOriginSlot = 1; +constexpr size_t GltfNodeAabbSizeSlot = 2; +constexpr uint32_t MaxExactGltfNodeIndex = 1U << 24; + +/** Convert a geometry address to the corresponding point-buffer storage index. */ +simfil::ArrayIndex geometryBufferIndex(ModelNodeAddress geometryAddress) +{ + return static_cast(geometryAddress.index()); +} + +template +/** Ensure GLTF node geometries expose the fixed [index, origin, size] slot layout. */ +void ensureGltfNodeStorageInitialized(StorageType& storage, simfil::ArrayIndex arrayIndex) +{ + auto const currentSize = storage.size(arrayIndex); + if (currentSize == 0) { + storage.emplace_back(arrayIndex, glm::vec3{0.0F, 0.0F, 0.0F}); + storage.emplace_back(arrayIndex, glm::vec3{0.0F, 0.0F, 0.0F}); + storage.emplace_back(arrayIndex, glm::vec3{0.0F, 0.0F, 0.0F}); + return; + } + if (currentSize != 3) { + raiseFmt("GLTF node geometry expects exactly three stored entries, found {}.", currentSize); + } +} + } /** Model node impls. for GeometryCollection */ @@ -193,6 +346,11 @@ ModelNode::Ptr GeometryCollection::singleGeom() const return {}; } +nlohmann::json GeometryCollection::toJson() const +{ + return simfil::MandatoryDerivedModelNodeBase::toJson(); +} + void GeometryCollection::addGeometry(const model_ptr& geom) { if (addr_.column() != TileFeatureLayer::ColumnId::GeometryCollections) { @@ -365,6 +523,15 @@ Geometry::Geometry(ViewData* data, ModelConstPtr pool_, ModelNodeAddress a, simf uint64_t Geometry::getHash() const { + if (geomType() == GeomType::GltfNodeIndex) { + Hash result; + auto const origin = gltfNodeAabbOrigin(); + auto const size = gltfNodeAabbSize(); + result.mix(gltfNodeIndex()) + .mix(origin.x).mix(origin.y).mix(origin.z) + .mix(size.x).mix(size.y).mix(size.z); + return result.value(); + } Hash result; forEachPoint([&result](Point const& p) { @@ -387,6 +554,16 @@ std::optional Geometry::stage() const return std::nullopt; } +void Geometry::setStage(std::optional geometryStage) +{ + if (geometryStage && *geometryStage > static_cast(std::numeric_limits::max())) { + raise("Geometry::setStage: stage is out of uint8_t range."); + } + model().setGeometryStage( + addr_, + geometryStage ? std::optional{static_cast(*geometryStage)} : std::nullopt); +} + SelfContainedGeometry Geometry::toSelfContained() const { SelfContainedGeometry result{{}, geomType()}; @@ -399,6 +576,11 @@ SelfContainedGeometry Geometry::toSelfContained() const return result; } +nlohmann::json Geometry::toJson() const +{ + return simfil::MandatoryDerivedModelNodeBase::toJson(); +} + ValueType Geometry::type() const { return ValueType::Object; } @@ -406,6 +588,7 @@ ValueType Geometry::type() const { ModelNode::Ptr Geometry::at(int64_t i) const { auto const sourceDataReferences = model().geometrySourceDataReferences(addr_); auto const geometryName = name(); + auto const type = geomType(); if (sourceDataReferences) { if (i == 0) return get(StringPool::SourceDataStr); @@ -420,13 +603,21 @@ ModelNode::Ptr Geometry::at(int64_t i) const { return get(StringPool::TypeStr); if (i == 1) return get(StringPool::CoordinatesStr); + // AABB and GLTF-node geometries serialize as polygons plus one auxiliary + // metadata field so they remain consumable as GeoJSON. + if (type == GeomType::AABB && i == 2) + return get(StringPool::AabbStr); + if (type == GeomType::GltfNodeIndex && i == 2) + return get(StringPool::GltfNodeIndexStr); throw std::out_of_range("geom: Out of range."); } uint32_t Geometry::size() const { auto const sourceDataReferences = model().geometrySourceDataReferences(addr_); auto const geometryName = name(); - return 2 + (sourceDataReferences ? 1 : 0) + (geometryName ? 1 : 0); + auto const extraFields = + geomType() == GeomType::AABB || geomType() == GeomType::GltfNodeIndex ? 1U : 0U; + return 2 + extraFields + (sourceDataReferences ? 1 : 0) + (geometryName ? 1 : 0); } ModelNode::Ptr Geometry::get(const StringId& f) const { @@ -437,26 +628,59 @@ ModelNode::Ptr Geometry::get(const StringId& f) const { return model().resolve(sourceDataReferences); } if (f == StringPool::GeometryNameStr && geometryName) { - return model_ptr::make(*geometryName, model_); + return model_ptr::make(std::string_view(*geometryName), model_); } if (f == StringPool::TypeStr) { - return model_ptr::make( - type == GeomType::Points ? MultiPointStr : - type == GeomType::Line ? LineStringStr : - type == GeomType::Polygon ? PolygonStr : - type == GeomType::Mesh ? MultiPolygonStr : "", - model_); + std::string_view typeName; + switch (type) { + case GeomType::Points: + typeName = MultiPointStr; + break; + case GeomType::Line: + typeName = LineStringStr; + break; + case GeomType::Polygon: + typeName = PolygonStr; + break; + case GeomType::Mesh: + typeName = MultiPolygonStr; + break; + case GeomType::AABB: + // Bounds-only geometries are exported as footprint polygons plus an + // auxiliary `aabb` object containing the full 3D extent. + typeName = PolygonStr; + break; + case GeomType::GltfNodeIndex: + // GLTF node references also export their bounds footprint so generic + // GeoJSON consumers can still place them spatially. + typeName = PolygonStr; + break; + } + return model_ptr::make(std::string_view(typeName), model_); + } + if (f == StringPool::AabbStr && type == GeomType::AABB) { + return makeBoundsInfoView(model(), addr_); + } + if (f == StringPool::GltfNodeIndexStr && type == GeomType::GltfNodeIndex) { + return model_ptr::make(static_cast(gltfNodeIndex()), model_); } if (f == StringPool::CoordinatesStr) { switch (type) { + case GeomType::AABB: + return makeBoundsPolygonCoordinatesView(model(), addr_); + case GeomType::GltfNodeIndex: + return makeBoundsPolygonCoordinatesView(model(), addr_); case GeomType::Polygon: if (geomViewData_) { + // Geometry views may expose only a point subrange, so they fall + // back to the generic point-buffer view instead of polygon rings. break; } return model().resolve( ModelNodeAddress{TileFeatureLayer::ColumnId::Polygon, addr_.index()}); case GeomType::Mesh: if (geomViewData_) { + // Same for mesh views: only base meshes can present triangle collections. break; } return model().resolve( @@ -484,6 +708,8 @@ StringId Geometry::keyAt(int64_t i) const { } if (i == 0) return StringPool::TypeStr; if (i == 1) return StringPool::CoordinatesStr; + if (geomType() == GeomType::AABB && i == 2) return StringPool::AabbStr; + if (geomType() == GeomType::GltfNodeIndex && i == 2) return StringPool::GltfNodeIndexStr; throw std::out_of_range("geom: Out of range."); } @@ -505,15 +731,186 @@ void Geometry::append(Point const& p) if (geomViewData_) throw std::runtime_error("Cannot append to geometry view."); + if (geomType() == GeomType::GltfNodeIndex) { + throw std::runtime_error("Cannot append coordinates to a GltfNodeIndex geometry."); + } + + glm::vec3 storedPoint{}; + if (geomType() == GeomType::AABB && storage_->size(static_cast(addr_.index())) == 1) { + // The second AABB slot stores size, not another anchor-relative point. + storedPoint = glm::vec3{ + static_cast(p.x), + static_cast(p.y), + static_cast(p.z)}; + } else { + // Regular geometry vertices are stored relative to the tile anchor to + // preserve precision while keeping the on-disk representation compact. + auto const anchor = model().geometryAnchor(); + storedPoint = glm::vec3{ + static_cast(p.x - anchor.x), + static_cast(p.y - anchor.y), + static_cast(p.z - anchor.z)}; + } + + storage_->emplace_back(static_cast(addr_.index()), storedPoint); +} + +void Geometry::setAabb(Point const& origin, Point const& size) +{ + if (geomType() != GeomType::AABB) { + raise("setAabb is only valid on AABB geometries."); + } + if (geomViewData_) { + raise("Cannot mutate geometry view."); + } + + auto const arrayIndex = static_cast(addr_.index()); + auto const currentSize = storage_->size(arrayIndex); + if (currentSize == 0) { + // New AABBs reuse append() so the special origin/size storage layout is + // applied consistently. + append(origin); + append(size); + return; + } + if (currentSize != 2) { + raiseFmt("AABB geometry expects exactly two stored entries, found {}.", currentSize); + } + + auto const anchor = model().geometryAnchor(); + auto originSlot = storage_->at(arrayIndex, 0); + auto sizeSlot = storage_->at(arrayIndex, 1); + if (!originSlot || !sizeSlot) { + raise("Failed to access AABB storage."); + } + originSlot->get() = glm::vec3{ + static_cast(origin.x - anchor.x), + static_cast(origin.y - anchor.y), + static_cast(origin.z - anchor.z)}; + sizeSlot->get() = glm::vec3{ + static_cast(size.x), + static_cast(size.y), + static_cast(size.z)}; +} + +Point Geometry::aabbOrigin() const +{ + if (geomType() != GeomType::AABB) { + raise("aabbOrigin is only valid on AABB geometries."); + } + if (numPoints() != 2) { + raiseFmt("AABB geometry expects exactly two points, found {}.", numPoints()); + } + return pointAt(0); +} + +Point Geometry::aabbSize() const +{ + if (geomType() != GeomType::AABB) { + raise("aabbSize is only valid on AABB geometries."); + } + if (numPoints() != 2) { + raiseFmt("AABB geometry expects exactly two points, found {}.", numPoints()); + } + return pointAt(1); +} + +void Geometry::setGltfNodeIndex(uint32_t index) +{ + if (geomType() != GeomType::GltfNodeIndex) { + raise("setGltfNodeIndex is only valid on GltfNodeIndex geometries."); + } + if (geomViewData_) { + raise("Cannot mutate geometry view."); + } + if (index > MaxExactGltfNodeIndex) { + // The node index lives in a float-backed storage slot, so only integers + // within the exact mantissa range are lossless. + raiseFmt( + "GLTF node index {} exceeds the exact float storage limit of {}.", + index, + MaxExactGltfNodeIndex); + } + + auto const arrayIndex = geometryBufferIndex(addr_); + ensureGltfNodeStorageInitialized(*storage_, arrayIndex); + auto indexSlot = storage_->at(arrayIndex, GltfNodeIndexSlot); + if (!indexSlot) { + raise("Failed to access GLTF node index storage."); + } + indexSlot->get() = glm::vec3{0.0F, 0.0F, static_cast(index)}; +} + +uint32_t Geometry::gltfNodeIndex() const +{ + if (geomType() != GeomType::GltfNodeIndex) { + raise("gltfNodeIndex is only valid on GltfNodeIndex geometries."); + } + auto const arrayIndex = geometryBufferIndex(addr_); + auto indexSlot = storage_->at(arrayIndex, GltfNodeIndexSlot); + if (!indexSlot) { + raise("Failed to access GLTF node index storage."); + } + return static_cast(std::lround(indexSlot->get().z)); +} + +void Geometry::setGltfNodeBounds(Point const& origin, Point const& size) +{ + if (geomType() != GeomType::GltfNodeIndex) { + raise("setGltfNodeBounds is only valid on GltfNodeIndex geometries."); + } + if (geomViewData_) { + raise("Cannot mutate geometry view."); + } + + auto const arrayIndex = geometryBufferIndex(addr_); + ensureGltfNodeStorageInitialized(*storage_, arrayIndex); + auto originSlot = storage_->at(arrayIndex, GltfNodeAabbOriginSlot); + auto sizeSlot = storage_->at(arrayIndex, GltfNodeAabbSizeSlot); + if (!originSlot || !sizeSlot) { + raise("Failed to access GLTF node bounds storage."); + } + auto const anchor = model().geometryAnchor(); - auto const anchoredPoint = glm::vec3{ - static_cast(p.x - anchor.x), - static_cast(p.y - anchor.y), - static_cast(p.z - anchor.z)}; + originSlot->get() = glm::vec3{ + static_cast(origin.x - anchor.x), + static_cast(origin.y - anchor.y), + static_cast(origin.z - anchor.z)}; + sizeSlot->get() = glm::vec3{ + static_cast(size.x), + static_cast(size.y), + static_cast(size.z)}; +} - storage_->emplace_back( - static_cast(addr_.index()), - anchoredPoint); +Point Geometry::gltfNodeAabbOrigin() const +{ + if (geomType() != GeomType::GltfNodeIndex) { + raise("gltfNodeAabbOrigin is only valid on GltfNodeIndex geometries."); + } + auto const arrayIndex = geometryBufferIndex(addr_); + auto originSlot = storage_->at(arrayIndex, GltfNodeAabbOriginSlot); + if (!originSlot) { + raise("Failed to access GLTF node bounds origin."); + } + auto point = model().geometryAnchor(); + point += originSlot->get(); + return point; +} + +Point Geometry::gltfNodeAabbSize() const +{ + if (geomType() != GeomType::GltfNodeIndex) { + raise("gltfNodeAabbSize is only valid on GltfNodeIndex geometries."); + } + auto const arrayIndex = geometryBufferIndex(addr_); + auto sizeSlot = storage_->at(arrayIndex, GltfNodeAabbSizeSlot); + if (!sizeSlot) { + raise("Failed to access GLTF node bounds size."); + } + return Point{ + sizeSlot->get().x, + sizeSlot->get().y, + sizeSlot->get().z}; } GeomType Geometry::geomType() const { @@ -530,18 +927,31 @@ bool Geometry::iterate(const IterCallback& cb) const size_t Geometry::numPoints() const { + if (geomType() == GeomType::GltfNodeIndex) { + // GLTF node geometries expose only their node id and bounds; they do + // not behave like a coordinate buffer. + return 0; + } auto vertexBufferNode = model_ptr::make(model_, addr_); return vertexBufferNode->size(); } Point Geometry::pointAt(size_t index) const { + if (geomType() == GeomType::GltfNodeIndex) { + raise("GltfNodeIndex geometries do not expose coordinates."); + } auto vertexBufferNode = model_ptr::make(model_, addr_); return vertexBufferNode->pointAt(static_cast(index)); } double Geometry::length() const { + if (geomType() == GeomType::GltfNodeIndex || geomType() == GeomType::AABB) { + // Bounds-only geometries and GLTF node references do not define a + // traversable line length. + return 0.0; + } auto length = 0.0; if (numPoints() < 2) return length; for (auto i = 0; i < numPoints()-1; ++i) @@ -683,6 +1093,8 @@ Point Geometry::percentagePositionFromGeometries(std::vector percentagePosition -= lengths[i]; } else { + // Once the target falls into a segment geometry, reuse the + // length-bound sampling helper to get the final point. auto points = geoms[i]->pointsFromLengthBound(percentagePosition, std::nullopt); if (points.empty()) { break; @@ -694,6 +1106,154 @@ Point Geometry::percentagePositionFromGeometries(std::vector return positionPoint; } +/** ModelNode impls. for bounds helper views */ + +BoundsInfoNode::BoundsInfoNode(ModelNode const& baseNode, simfil::detail::mp_key key) + : simfil::MandatoryDerivedModelNodeBase(baseNode, key), + baseGeometryAddress_(decodeGeometryHelperBaseAddress(addr_, std::get(data_))) +{} + +ValueType BoundsInfoNode::type() const +{ + return ValueType::Object; +} + +ModelNode::Ptr BoundsInfoNode::at(int64_t i) const +{ + if (i == 0) return get(StringPool::OriginStr); + if (i == 1) return get(StringPool::SizeStr); + throw std::out_of_range("bounds-info: Out of range."); +} + +uint32_t BoundsInfoNode::size() const +{ + return 2; +} + +ModelNode::Ptr BoundsInfoNode::get(const StringId& field) const +{ + if (field == StringPool::OriginStr) { + return makeGeometryPointView( + model(), + baseGeometryAddress_, + GeometryPointViewKind::BoundsOrigin); + } + if (field == StringPool::SizeStr) { + return makeGeometryPointView( + model(), + baseGeometryAddress_, + GeometryPointViewKind::BoundsSize); + } + return {}; +} + +StringId BoundsInfoNode::keyAt(int64_t i) const +{ + if (i == 0) return StringPool::OriginStr; + if (i == 1) return StringPool::SizeStr; + throw std::out_of_range("bounds-info: Out of range."); +} + +bool BoundsInfoNode::iterate(const IterCallback& cb) const +{ + if (!cb(*at(0))) return false; + if (!cb(*at(1))) return false; + return true; +} + +BoundsPolygonCoordinatesNode::BoundsPolygonCoordinatesNode( + ModelNode const& baseNode, + simfil::detail::mp_key key) + : simfil::MandatoryDerivedModelNodeBase(baseNode, key), + baseGeometryAddress_(decodeGeometryHelperBaseAddress(addr_, std::get(data_))) +{} + +ValueType BoundsPolygonCoordinatesNode::type() const +{ + return ValueType::Array; +} + +ModelNode::Ptr BoundsPolygonCoordinatesNode::at(int64_t i) const +{ + if (i != 0) { + throw std::out_of_range("bounds-polygon: Out of range."); + } + return makeBoundsRingView(model(), baseGeometryAddress_); +} + +uint32_t BoundsPolygonCoordinatesNode::size() const +{ + return 1; +} + +ModelNode::Ptr BoundsPolygonCoordinatesNode::get(const StringId&) const +{ + return {}; +} + +StringId BoundsPolygonCoordinatesNode::keyAt(int64_t) const +{ + return {}; +} + +bool BoundsPolygonCoordinatesNode::iterate(const IterCallback& cb) const +{ + return cb(*at(0)); +} + +BoundsRingNode::BoundsRingNode(ModelNode const& baseNode, simfil::detail::mp_key key) + : simfil::MandatoryDerivedModelNodeBase(baseNode, key), + baseGeometryAddress_(decodeGeometryHelperBaseAddress(addr_, std::get(data_))) +{} + +ValueType BoundsRingNode::type() const +{ + return ValueType::Array; +} + +ModelNode::Ptr BoundsRingNode::at(int64_t i) const +{ + switch (i) { + case 0: + return makeGeometryPointView(model(), baseGeometryAddress_, GeometryPointViewKind::BoundsCorner0); + case 1: + return makeGeometryPointView(model(), baseGeometryAddress_, GeometryPointViewKind::BoundsCorner1); + case 2: + return makeGeometryPointView(model(), baseGeometryAddress_, GeometryPointViewKind::BoundsCorner2); + case 3: + return makeGeometryPointView(model(), baseGeometryAddress_, GeometryPointViewKind::BoundsCorner3); + case 4: + return makeGeometryPointView(model(), baseGeometryAddress_, GeometryPointViewKind::BoundsCorner4); + default: + throw std::out_of_range("bounds-ring: Out of range."); + } +} + +uint32_t BoundsRingNode::size() const +{ + return 5; +} + +ModelNode::Ptr BoundsRingNode::get(const StringId&) const +{ + return {}; +} + +StringId BoundsRingNode::keyAt(int64_t) const +{ + return {}; +} + +bool BoundsRingNode::iterate(const IterCallback& cb) const +{ + for (int64_t i = 0; i < 5; ++i) { + if (!cb(*at(i))) { + return false; + } + } + return true; +} + /** ModelNode impls. for PolygonNode */ PolygonNode::PolygonNode(ModelConstPtr pool, ModelNodeAddress const& a, simfil::detail::mp_key key) @@ -965,6 +1525,8 @@ PointBufferNode::PointBufferNode( baseGeomAddress_ = viewData->baseGeometry_; while (baseGeomAddress_.column() == TileFeatureLayer::ColumnId::GeometryViews) { + // Nested views accumulate offsets until a real base geometry buffer + // is reached, so point access stays O(1) afterwards. viewData = model().geometryViewData(baseGeomAddress_); if (!viewData) { throw std::runtime_error("Failed to resolve nested geometry view."); @@ -997,13 +1559,18 @@ ValueType PointBufferNode::type() const { ModelNode::Ptr PointBufferNode::at(int64_t i) const { if (i < 0 || i >= size()) throw std::out_of_range("vertex-buffer: Out of range."); - i += offset_; + auto const absoluteIndex = i + offset_; + if (baseGeomAddress_.column() == TileFeatureLayer::ColumnId::AabbGeometries && + absoluteIndex == 1) { + return makeGeometryPointView( + model(), + baseGeomAddress_, + GeometryPointViewKind::RawSize); + } auto const pointNodeAddress = ModelNodeAddress{ TileFeatureLayer::ColumnId::Points, baseGeomAddress_.index()}; - return model().resolve( - pointNodeAddress, - i); + return model().resolve(pointNodeAddress, absoluteIndex); } uint32_t PointBufferNode::size() const { @@ -1020,21 +1587,12 @@ StringId PointBufferNode::keyAt(int64_t) const { bool PointBufferNode::iterate(const IterCallback& cb) const { - auto cont = true; - auto resolveAndCb = Model::Lambda([&cb, &cont](auto && node){ - cont = cb(node); - }); - auto const pointNodeAddress = ModelNodeAddress{ - TileFeatureLayer::ColumnId::Points, - baseGeomAddress_.index()}; for (auto i = 0u; i < size_; ++i) { - resolveAndCb(*model().resolve( - pointNodeAddress, - static_cast(i) + offset_)); - if (!cont) - break; + if (!cb(*at(static_cast(i)))) { + return false; + } } - return cont; + return true; } Point PointBufferNode::pointAt(int64_t index) const @@ -1048,6 +1606,13 @@ Point PointBufferNode::pointAt(int64_t index) const if (!vertexResult) { throw std::out_of_range("vertex-buffer: Out of range."); } + if (baseGeomAddress_.column() == TileFeatureLayer::ColumnId::AabbGeometries && + index + static_cast(offset_) == 1) { + return Point{ + vertexResult->get().x, + vertexResult->get().y, + vertexResult->get().z}; + } auto point = model().geometryAnchor(); point += vertexResult->get(); return point; diff --git a/libs/model/src/info.cpp b/libs/model/src/info.cpp index c7832d48..1b037691 100644 --- a/libs/model/src/info.cpp +++ b/libs/model/src/info.cpp @@ -13,12 +13,14 @@ namespace mapget namespace { +/** Standardize missing-field errors across model metadata JSON parsers. */ auto missing_field(std::string const& error, std::string const& context) { return std::runtime_error( fmt::format("{}::fromJson(): `{}`", context, error)); } template +/** Parse a full string into a number and reject trailing characters. */ std::optional from_chars(std::string_view s, Args... args) { auto end = s.data() + s.size(); @@ -164,6 +166,9 @@ std::optional IdPart::compositionMatchEndIndex( // Does this ID part's field name match? if (compositionIter->idPartLabel_ != idPartKey) { if (compositionIter->isOptional_) { + // Optional composition slots may be absent entirely, so keep + // scanning until we either find the requested part or hit a + // required mismatch. ++compositionIter; continue; } @@ -208,6 +213,8 @@ bool IdPart::idPartsMatchComposition( auto compositionIter = candidateComposition.begin() + *matchEndIndex; while (compositionIter != candidateComposition.end()) { if (!compositionIter->isOptional_) { + // Exact feature ids must consume every remaining required part + // of the composition. Only optional tail fields may be omitted. return false; } ++compositionIter; @@ -223,7 +230,8 @@ bool IdPart::validate(std::variant& val, std::string* erro auto& strVal = std::get(val); auto result = std::variant(strVal); auto resultBool = validate(result, error); - // The string value may have been turned into an integer. + // Numeric id parts accept string input during parsing, but normalize to + // integers once validation succeeds. if (std::holds_alternative(result)) { val = std::get(result); } @@ -279,6 +287,8 @@ bool IdPart::validate(std::variant& val, std::string* auto expectUuid128 = [&expectString]() -> bool { return expectString([](auto const& strVal, auto error){ + // UUID128 ids are stored as the mapget-specific 16-character token, + // not a hyphenated RFC 4122 string. if (strVal.size() != 16) { if (error) *error = fmt::format("Value for {} must have 16 characters!", strVal); @@ -345,11 +355,24 @@ Coverage Coverage::fromJson(const nlohmann::json& j) { try { if (j.is_number_unsigned()) + // A bare integer is shorthand for a single covered tile. return { j.get(), j.get(), std::vector() }; + if (j.is_number_integer()) { + // YAML ingestion may materialize the same shorthand as a signed + // integer, so accept it as long as the tile id stays non-negative. + auto tileId = j.get(); + if (tileId < 0) + raise("Coverage tile ID must be non-negative."); + return { + static_cast(tileId), + static_cast(tileId), + std::vector() + }; + } return { TileId(j.at("min").get()), TileId(j.at("max").get()), @@ -388,6 +411,8 @@ std::shared_ptr LayerInfo::fromJson(const nlohmann::json& j, std::str const auto stages = std::max(1U, j.value("stages", 1U)); auto stageLabels = j.value("stageLabels", std::vector{}); if (stageLabels.size() < stages) { + // Import pads missing labels so stage index -> label lookup remains + // total even when metadata only names a few stages explicitly. stageLabels.reserve(stages); for (uint32_t i = static_cast(stageLabels.size()); i < stages; ++i) { stageLabels.emplace_back(fmt::format("Stage {}", i)); @@ -397,6 +422,8 @@ std::shared_ptr LayerInfo::fromJson(const nlohmann::json& j, std::str const auto highFidelityStage = std::min( stages - 1U, j.value("highFidelityStage", defaultHighFidelityStage)); + // High-fidelity stage is clamped into the configured stage range so + // downstream geometry-name lookups never index past the metadata. return std::make_shared(LayerInfo{ j.value("layerId", layerId), @@ -482,6 +509,8 @@ std::optional LayerInfo::matchingFeatureIdCompositionIndex( // References may use alternative ID compositions, // but the feature itself must always use the first (primary) one. if (validateForNewFeature) + // Once the primary composition failed, later alternatives are not + // considered for concrete feature creation. return std::nullopt; } @@ -521,6 +550,8 @@ DataSourceInfo DataSourceInfo::fromJson(const nlohmann::json& j) if (j.contains("nodeId")) nodeId = j.at("nodeId").get(); else + // Datasource metadata may omit a stable node id for ad-hoc JSON + // sources, so synthesize one to keep string-pool ownership valid. nodeId = generateNodeHexUuid(); return { diff --git a/libs/model/src/json-compare.cpp b/libs/model/src/json-compare.cpp new file mode 100644 index 00000000..d319854f --- /dev/null +++ b/libs/model/src/json-compare.cpp @@ -0,0 +1,227 @@ +#include "mapget/model/json-compare.h" + +#include +#include +#include +#include + +namespace mapget +{ +namespace +{ + +/** Recursively compare JSON values while collecting path-qualified errors. */ +bool compareJsonWithToleranceImpl( + nlohmann::json const& expected, + nlohmann::json const& actual, + std::string const& path, + double floatTolerance, + std::vector* errors) +{ + auto const currentPath = path.empty() ? "$" : path; + + if ((expected.type() != actual.type()) && !(expected.is_number() && actual.is_number())) { + if (errors) { + errors->push_back( + "Type mismatch at " + currentPath + ": " + expected.type_name() + " vs " + + actual.type_name()); + } + return false; + } + + if (expected.is_number() && actual.is_number()) { + if (!expected.is_number_float() && !actual.is_number_float()) { + // Keep integer-only comparisons exact so id-like fields do not silently drift. + if (expected != actual) { + if (errors) { + errors->push_back( + "Value mismatch at " + currentPath + ": " + expected.dump() + " vs " + + actual.dump()); + } + return false; + } + return true; + } + + // Float-vs-float and int-vs-float comparisons are normalized through double tolerance. + if (!nearlyEqual(expected.get(), actual.get(), floatTolerance)) { + if (errors) { + std::ostringstream message; + message << "Float mismatch at " << currentPath << ": " << expected.get() + << " vs " << actual.get(); + errors->push_back(message.str()); + } + return false; + } + return true; + } + + switch (expected.type()) { + case nlohmann::json::value_t::object: { + auto matches = true; + if (expected.size() != actual.size()) { + if (errors) { + errors->push_back( + "Object size mismatch at " + currentPath + ": " + + std::to_string(expected.size()) + " vs " + std::to_string(actual.size())); + } + matches = false; + } + + for (auto const& item : expected.items()) { + auto const childPath = currentPath + "." + item.key(); + auto actualIt = actual.find(item.key()); + if (actualIt == actual.end()) { + if (errors) { + errors->push_back("Missing key at " + childPath); + } + matches = false; + continue; + } + matches = compareJsonWithToleranceImpl( + item.value(), + *actualIt, + childPath, + floatTolerance, + errors) && + matches; + } + return matches; + } + case nlohmann::json::value_t::array: { + if (expected.size() != actual.size()) { + if (errors) { + errors->push_back( + "Array size mismatch at " + currentPath + ": " + + std::to_string(expected.size()) + " vs " + std::to_string(actual.size())); + } + return false; + } + + auto matches = true; + for (size_t i = 0; i < expected.size(); ++i) { + matches = compareJsonWithToleranceImpl( + expected[i], + actual[i], + currentPath + "[" + std::to_string(i) + "]", + floatTolerance, + errors) && + matches; + } + return matches; + } + default: + if (expected != actual) { + if (errors) { + errors->push_back( + "Value mismatch at " + currentPath + ": " + expected.dump() + " vs " + + actual.dump()); + } + return false; + } + return true; + } +} + +} // namespace + +/** Compare floats robustly across quantization and serialization roundtrips. */ +bool nearlyEqual(double a, double b, double epsilon) +{ + auto const absA = std::abs(a); + auto const absB = std::abs(b); + auto const diff = std::abs(a - b); + + if (a == b) { + return true; + } + if (a == 0.0 || b == 0.0 || diff < std::numeric_limits::min()) { + // Near zero, pure relative error becomes unstable, so fall back to absolute error. + return diff < epsilon; + } + + auto const relDiff = diff / std::min((absA + absB), std::numeric_limits::max()); + return relDiff < epsilon; +} + +/** Public entry point for tolerant full-document comparison. */ +bool compareJsonWithTolerance( + nlohmann::json const& expected, + nlohmann::json const& actual, + double floatTolerance, + std::vector* errors) +{ + return compareJsonWithToleranceImpl(expected, actual, "", floatTolerance, errors); +} + +/** Public entry point that compares only per-feature payloads. */ +bool compareFeatureCollectionJsonWithTolerance( + nlohmann::json const& expected, + nlohmann::json const& actual, + double floatTolerance, + std::vector* errors) +{ + if (!expected.contains("features") || !actual.contains("features")) { + if (errors) { + errors->push_back("Both JSON values must contain a top-level 'features' array."); + } + return false; + } + if (!expected["features"].is_array() || !actual["features"].is_array()) { + if (errors) { + errors->push_back("Top-level 'features' must be arrays."); + } + return false; + } + if (expected["features"].size() != actual["features"].size()) { + if (errors) { + errors->push_back( + "Feature array size mismatch: " + std::to_string(expected["features"].size()) + + " vs " + std::to_string(actual["features"].size())); + } + return false; + } + + auto matches = true; + for (size_t i = 0; i < expected["features"].size(); ++i) { + auto const& expectedFeature = expected["features"][i]; + auto const& actualFeature = actual["features"][i]; + auto const featureId = + expectedFeature.contains("id") && expectedFeature["id"].is_string() + ? expectedFeature["id"].get() + : std::string(""); + + std::vector featureErrors; + auto const featureMatches = compareJsonWithToleranceImpl( + expectedFeature, + actualFeature, + "$.features[" + std::to_string(i) + "]", + floatTolerance, + &featureErrors); + if (!featureMatches) { + matches = false; + if (errors) { + // Prefix child errors with feature identity so large snapshots stay debuggable. + for (auto const& error : featureErrors) { + errors->push_back( + "Feature " + featureId + " at index " + std::to_string(i) + ": " + + error); + } + } + } + } + + return matches; +} + +/** Render collected comparison errors into test-friendly multiline output. */ +std::string formatJsonComparisonErrors(std::vector const& errors) +{ + std::ostringstream output; + for (auto const& error : errors) { + output << error << '\n'; + } + return output.str(); +} + +} diff --git a/libs/model/src/pointnode.cpp b/libs/model/src/pointnode.cpp index 6e9300be..935ce53c 100644 --- a/libs/model/src/pointnode.cpp +++ b/libs/model/src/pointnode.cpp @@ -7,6 +7,23 @@ using namespace simfil; namespace mapget { +namespace +{ +Point boundsOrigin(model_ptr const& geometry) +{ + return geometry->geomType() == GeomType::AABB + ? geometry->aabbOrigin() + : geometry->gltfNodeAabbOrigin(); +} + +Point boundsSize(model_ptr const& geometry) +{ + return geometry->geomType() == GeomType::AABB + ? geometry->aabbSize() + : geometry->gltfNodeAabbSize(); +} +} + /** Model node impls for VertexNode. */ PointNode::PointNode( @@ -43,6 +60,44 @@ PointNode::PointNode(ModelNode const& baseNode, } } +PointNode::PointNode(ModelNode const& baseNode, simfil::detail::mp_key key) + : simfil::MandatoryDerivedModelNodeBase(baseNode, key) +{ + auto const encoded = std::get(data_); + auto const baseGeometryAddress = decodeGeometryHelperBaseAddress(addr_, encoded); + auto const kind = decodeGeometryPointViewKind(encoded); + auto const geometry = model().resolve(baseGeometryAddress); + auto const origin = boundsOrigin(geometry); + auto const size = boundsSize(geometry); + + switch (kind) { + case GeometryPointViewKind::RawSize: + point_ = geometry->aabbSize(); + break; + case GeometryPointViewKind::BoundsOrigin: + point_ = origin; + break; + case GeometryPointViewKind::BoundsSize: + point_ = size; + break; + case GeometryPointViewKind::BoundsCorner0: + point_ = {origin.x, origin.y, origin.z}; + break; + case GeometryPointViewKind::BoundsCorner1: + point_ = {origin.x + size.x, origin.y, origin.z}; + break; + case GeometryPointViewKind::BoundsCorner2: + point_ = {origin.x + size.x, origin.y + size.y, origin.z}; + break; + case GeometryPointViewKind::BoundsCorner3: + point_ = {origin.x, origin.y + size.y, origin.z}; + break; + case GeometryPointViewKind::BoundsCorner4: + point_ = {origin.x, origin.y, origin.z}; + break; + } +} + ValueType PointNode::type() const { return ValueType::Array; } diff --git a/libs/model/src/relation.cpp b/libs/model/src/relation.cpp index 8586343d..8ace79c1 100644 --- a/libs/model/src/relation.cpp +++ b/libs/model/src/relation.cpp @@ -38,7 +38,7 @@ Relation::Relation(Relation::Data* data, fields_.emplace_back( StringPool::TargetStr, [](Relation const& self) { - return self.model().resolve(self.data_->targetFeatureId_); + return self.target(); }); if (data_->sourceValidity_) fields_.emplace_back( diff --git a/libs/model/src/stream.cpp b/libs/model/src/stream.cpp index 828d002a..4365f228 100644 --- a/libs/model/src/stream.cpp +++ b/libs/model/src/stream.cpp @@ -38,10 +38,14 @@ void TileLayerStream::Reader::read(const std::string_view& bytes) while (continueReading()) {} if (readOffset_ == buffer_.size()) { + // Fully consumed buffers are reset eagerly so long-running streams do + // not retain capacity proportional to peak message size. buffer_.clear(); readOffset_ = 0; } else if (readOffset_ > 65536 && (readOffset_ * 2 > buffer_.size())) { + // For partial consumption, compact only once the consumed prefix is + // large enough to pay for the memmove. buffer_.erase( buffer_.begin(), buffer_.begin() + static_cast(readOffset_)); @@ -58,6 +62,8 @@ bool TileLayerStream::Reader::continueReading() { if (currentPhase_ == Phase::ReadHeader) { + // The stream is framed, so parsing alternates strictly between header + // and payload phases until the current message is complete. size_t headerBytesRead = 0; auto unreadBytes = std::span(buffer_).subspan(readOffset_); if (readMessageHeader(unreadBytes, nextValueType_, nextValueSize_, &headerBytesRead)) { @@ -99,6 +105,8 @@ bool TileLayerStream::Reader::continueReading() } else if (nextValueType_ == MessageType::StringPool) { + // String-pool updates are applied in-band so subsequent layer payloads + // in the same stream can resolve freshly introduced string ids. size_t nodeIdBytesRead = 0; auto stringPoolNodeId = StringPool::readDataSourceNodeId(payload, 0, &nodeIdBytesRead); auto result = stringPoolProvider_->getStringPool(stringPoolNodeId)->read(payload, nodeIdBytesRead); @@ -141,6 +149,8 @@ bool TileLayerStream::Reader::readMessageHeader( static_cast>(s.adapter().error()))); } if (!protocolVersion.isCompatible(CurrentProtocolVersion)) { + // Stream compatibility is defined at the Version major/minor level. + // Patch releases may still exchange the same wire format. raise(fmt::format( "Unable to read message with version {} using version {}.", protocolVersion.toString(), @@ -172,13 +182,17 @@ void TileLayerStream::Writer::write(TileLayer::Ptr const& tileLayer) if (highestStringKnownToClient < highestString) { - // Need to send the client an update for the string pool. + // String pools are streamed ahead of tile payloads so ids inside + // the upcoming layer bytes are immediately resolvable client-side. std::string serializedStrings; serializedStrings.reserve(1024); // Pre-allocate for typical string pool update { std::ostringstream stringsStream; auto stringUpdateOffset = 0; if (differentialStringUpdates_) + // Differential mode sends only strings the client has + // not acknowledged yet; caches must disable this and + // store complete pools instead. stringUpdateOffset = highestStringKnownToClient + 1; strings->write(stringsStream, stringUpdateOffset); serializedStrings = stringsStream.str(); @@ -210,6 +224,7 @@ void TileLayerStream::Writer::write(TileLayer::Ptr const& tileLayer) case mapget::LayerType::SourceData: return MessageType::TileSourceDataLayer; default: + // Other layer types currently have no binary stream encoding. raiseFmt("Unsupported layer type: {}", static_cast(layerType)); } return MessageType::None; @@ -221,7 +236,7 @@ void TileLayerStream::Writer::write(TileLayer::Ptr const& tileLayer) void TileLayerStream::Writer::sendMessage(std::string&& bytes, TileLayerStream::MessageType msgType) { // TODO refactor the preparation of tile layer & field dicts storage format - // such that the encoding logic is not split over multiple functions. + // such that the encoding logic is not split over multiple functions. // Calculate actual header size // Protocol version: ~10 bytes (depending on version object size) @@ -245,7 +260,8 @@ void TileLayerStream::Writer::sendMessage(std::string&& bytes, TileLayerStream:: message = headerStream.str(); } - // Append content with move semantics + // Append the framed payload bytes after the header to produce one contiguous + // message for the caller's transport layer. message.append(std::move(bytes)); // Send with move semantics @@ -267,8 +283,9 @@ std::shared_ptr TileLayerStream::StringPoolCache::getStringPool(cons } } { - std::unique_lock stringPoolWriteLock(stringPoolCacheMutex_, std::defer_lock); - // Was it inserted already now? + std::unique_lock stringPoolWriteLock(stringPoolCacheMutex_); + // Another thread may have populated the cache between the shared-read + // miss and taking the write lock, so check again before inserting. auto it = stringPoolPerNodeId_.find(std::string(nodeId)); if (it != stringPoolPerNodeId_.end()) return it->second; diff --git a/libs/model/src/stringpool.cpp b/libs/model/src/stringpool.cpp index 3946bc53..88bb1bcc 100644 --- a/libs/model/src/stringpool.cpp +++ b/libs/model/src/stringpool.cpp @@ -41,6 +41,10 @@ StringPool::StringPool(const std::string_view& nodeId) : nodeId_(nodeId) { addStaticKey(EndStr, "end"); addStaticKey(PointStr, "point"); addStaticKey(FeatureIdStr, "featureId"); + addStaticKey(AabbStr, "aabb"); + addStaticKey(OriginStr, "origin"); + addStaticKey(SizeStr, "size"); + addStaticKey(GltfNodeIndexStr, "gltfNodeIndex"); addStaticKey(FromStr, "from"); addStaticKey(ToStr, "to"); addStaticKey(ConnectedEndStr, "connectedEnd"); diff --git a/libs/model/src/validity.cpp b/libs/model/src/validity.cpp index 3165fb45..7c33a99b 100644 --- a/libs/model/src/validity.cpp +++ b/libs/model/src/validity.cpp @@ -11,6 +11,7 @@ namespace mapget namespace { +/** Convert a validity direction enum into the exported JSON token. */ std::string_view directionToString(Validity::Direction const& d) { switch (d) { @@ -23,6 +24,7 @@ std::string_view directionToString(Validity::Direction const& d) return "?"; } +/** Convert a transition endpoint enum into the exported JSON token. */ std::string_view transitionEndToString(Validity::TransitionEnd const& end) { switch (end) { @@ -32,6 +34,41 @@ std::string_view transitionEndToString(Validity::TransitionEnd const& end) return "?"; } +constexpr uint64_t SimpleValidityOwnerTag = 0x01ull << 56U; + +/** Encode the owning validity-collection slot for a compact simple validity. */ +int64_t encodeSimpleValidityOwner(simfil::ArrayIndex members, uint32_t elementIndex) +{ + if (members == simfil::InvalidArrayIndex || members > 0x00ffffffu) { + raise("SimpleValidity owner members index out of range."); + } + + return static_cast( + SimpleValidityOwnerTag | + (static_cast(members) << 32U) | + static_cast(elementIndex)); +} + +/** Decode the owning validity-collection slot for a compact simple validity. */ +std::optional> decodeSimpleValidityOwner( + simfil::ScalarValueType const& runtimeData) +{ + auto const* encodedOwner = std::get_if(&runtimeData); + if (!encodedOwner) { + return std::nullopt; + } + + auto const raw = static_cast(*encodedOwner); + if ((raw & 0xff00000000000000ull) != SimpleValidityOwnerTag) { + return std::nullopt; + } + + return std::pair{ + static_cast((raw >> 32U) & 0x00ffffffu), + static_cast(raw & 0xffffffffu)}; +} + +/** Pack both transition endpoint flags into the compact stored bitfield. */ uint8_t packTransitionEnds( Validity::TransitionEnd fromConnectedEnd, Validity::TransitionEnd toConnectedEnd) @@ -41,16 +78,19 @@ uint8_t packTransitionEnds( (static_cast(toConnectedEnd) << 1U)); } +/** Decode the source endpoint from the stored transition bitfield. */ Validity::TransitionEnd unpackFromConnectedEnd(uint8_t packedEnds) { return (packedEnds & 0x1U) != 0 ? Validity::End : Validity::Start; } +/** Decode the target endpoint from the stored transition bitfield. */ Validity::TransitionEnd unpackToConnectedEnd(uint8_t packedEnds) { return (packedEnds & 0x2U) != 0 ? Validity::End : Validity::Start; } +/** Pick the line geometry that should be used for line-based validity resolution. */ model_ptr resolveLineGeometry( model_ptr const& geometryCollection, std::optional referencedStage) @@ -67,11 +107,13 @@ struct TransitionSegment Point inner_; }; +/** Compare two validity points with a small tolerance to absorb numeric noise. */ bool pointsCoincide(Point const& left, Point const& right) { return left.distanceTo(right) < 1e-9; } +/** Resolve the endpoint segment that participates in a semantic feature transition. */ std::optional resolveTransitionSegment( model_ptr const& feature, Validity::TransitionEnd connectedEnd, @@ -91,6 +133,8 @@ std::optional resolveTransitionSegment( auto outerIndex = innerIndex; auto const innerPoint = geometry->pointAt(innerIndex); if (connectedEnd == Validity::End) { + // Transition endpoints may be repeated at the tail, so walk backwards + // until the first distinct point to get a visible outgoing segment. for (auto pointIndex = innerIndex; pointIndex-- > 0;) { if (!pointsCoincide(geometry->pointAt(pointIndex), innerPoint)) { outerIndex = pointIndex; @@ -98,6 +142,8 @@ std::optional resolveTransitionSegment( } } } else { + // Likewise, repeated points at the head must be skipped when entering + // a transition from the start of a polyline. for (auto pointIndex = innerIndex + 1U; pointIndex < numPoints; ++pointIndex) { if (!pointsCoincide(geometry->pointAt(pointIndex), innerPoint)) { outerIndex = pointIndex; @@ -111,16 +157,20 @@ std::optional resolveTransitionSegment( }; } +/** Apply direction semantics to a resolved geometry after the shape has been computed. */ SelfContainedGeometry applyDirectionToGeometry( SelfContainedGeometry geometry, Validity::Direction direction) { if (direction == Validity::Negative && geometry.points_.size() > 1) { + // Negative direction reuses the same geometric support but traverses it + // against the feature digitization order. std::reverse(geometry.points_.begin(), geometry.points_.end()); } return geometry; } +/** Map a stored geometry stage back to the optional exported `geometryName`. */ std::optional geometryNameForStage( TileFeatureLayer const& model, std::optional geometryStage) @@ -130,6 +180,7 @@ std::optional geometryNameForStage( } auto const& layerInfo = *model.layerInfo(); if (*geometryStage <= layerInfo.highFidelityStage_) { + // High-fidelity geometries intentionally omit a stage label in JSON. return std::nullopt; } if (*geometryStage >= layerInfo.stageLabels_.size()) { @@ -155,12 +206,24 @@ void Validity::ensureMaterialized() raise("Cannot materialize validity from non-simple address."); } - auto upgradedAddress = model().materializeSimpleValidity(simpleAddress, simpleDirection_); + auto owner = decodeSimpleValidityOwner(ModelNode::data_); + if (!owner) { + raise("Cannot materialize detached simple validity without owner context."); + } + + // Simple direction-only validities stay compact until one concrete + // occurrence needs richer state. Upgrade only the owning collection slot. + auto upgradedAddress = model().materializeSimpleValidity( + simpleAddress, + owner->first, + owner->second, + simpleDirection_); auto upgraded = model().resolve(upgradedAddress); if (!upgraded || !upgraded->data_) { raise("Failed to materialize simple validity."); } data_ = upgraded->data_; + fields_ = upgraded->fields_; } model_ptr Validity::featureId() const @@ -204,6 +267,17 @@ Validity::Validity( } } +Validity::Validity( + Validity::Direction direction, + simfil::ModelConstPtr layer, + simfil::ModelNodeAddress a, + simfil::ScalarValueType runtimeData, + simfil::detail::mp_key key) + : Validity(direction, std::move(layer), a, key) +{ + ModelNode::data_ = std::move(runtimeData); +} + Validity::Validity(Validity::Data* data, simfil::ModelConstPtr layer, simfil::ModelNodeAddress a, @@ -231,6 +305,8 @@ Validity::Validity(Validity::Data* data, } if (data_->geomDescrType_ == SimpleGeometry) { + // SimpleGeometry stores an explicit geometry node, so no offset or + // transition metadata fields are exposed in the JSON view. fields_.emplace_back( StringPool::GeometryStr, [](Validity const& self) @@ -242,6 +318,8 @@ Validity::Validity(Validity::Data* data, } if (data_->geomDescrType_ == FeatureTransition) { + // Semantic transitions serialize as feature references plus endpoint + // metadata instead of an explicit geometry payload. fields_.emplace_back( StringPool::TransitionNumberStr, [](Validity const& self) @@ -304,6 +382,8 @@ Validity::Validity(Validity::Data* data, }); } + // Offset-point and offset-range validities share the same storage; the + // exported field names depend on the selected offset interpretation. auto exposeOffsetPoint = [this](StringId fieldName, uint32_t pointIndex, Point const& p) { fields_.emplace_back( @@ -342,9 +422,7 @@ Validity::Validity(Validity::Data* data, StringPool::FeatureIdStr, [](Validity const& self) { - return model_ptr::make( - self.featureId()->toString(), - self.model_); + return self.featureId(); }); } } @@ -643,7 +721,8 @@ SelfContainedGeometry Validity::computeGeometry( return {}; } - // Resolve validity geometry by stage first (if specified), then by line type. + // Line-based validities always resolve against the preferred line geometry + // for the chosen stage, not against arbitrary polygons or meshes. auto geometry = resolveLineGeometry(geometryCollection, referencedStage); if (!geometry) { @@ -712,6 +791,8 @@ SelfContainedGeometry Validity::computeGeometry( } if (endPointIndex < startPointIndex) { + // Buffer indices are treated as an inclusive range independent of + // authoring order, so normalize to ascending storage order first. std::swap(startPointIndex, endPointIndex); } @@ -879,15 +960,53 @@ model_ptr MultiValidity::newComplete(Validity::Direction direction) model_ptr MultiValidity::newDirection(Validity::Direction direction) { + auto const elementIndex = size(); const auto simpleAddr = simfil::ModelNodeAddress{ TileFeatureLayer::ColumnId::SimpleValidity, static_cast(direction)}; - if (auto upgradedAddress = model().upgradedSimpleValidityAddress(simpleAddr)) { - appendInternal(model_ptr::make(model_, *upgradedAddress)); - return model().resolve(*upgradedAddress); - } appendInternal(model_ptr::make(model_, simpleAddr)); - return model().resolve(simpleAddr); + return model().resolve( + simpleAddr, + encodeSimpleValidityOwner(members_, elementIndex)); +} + +ModelNode::Ptr MultiValidity::at(int64_t i) const +{ + if (i < 0 || i >= static_cast(storage_->size(members_))) { + return {}; + } + + auto value = storage_->at(members_, static_cast(i)); + if (!value) { + return {}; + } + + auto const memberAddress = value->get(); + if (memberAddress.column() != TileFeatureLayer::ColumnId::SimpleValidity) { + return ModelNode::Ptr::make(model_, memberAddress); + } + + return ModelNode::Ptr::make( + model_, + memberAddress, + encodeSimpleValidityOwner(members_, static_cast(i))); +} + +bool MultiValidity::iterate(ModelNode::IterCallback const& cb) const +{ + bool cont = true; + auto resolveAndCb = simfil::Model::Lambda([&cb, &cont](auto&& node) { cont = cb(node); }); + for (int64_t index = 0; index < static_cast(size()); ++index) { + auto value = at(index); + if (!value) { + return false; + } + model_->resolve(*value, resolveAndCb); + if (!cont) { + return false; + } + } + return true; } } diff --git a/libs/pymapget/binding/py-datasource.h b/libs/pymapget/binding/py-datasource.h index 111ed926..29f6acb9 100644 --- a/libs/pymapget/binding/py-datasource.h +++ b/libs/pymapget/binding/py-datasource.h @@ -2,6 +2,7 @@ #include "mapget/http-datasource/datasource-server.h" +#include #include #include #include @@ -9,11 +10,140 @@ namespace py = pybind11; using namespace py::literals; +namespace +{ +py::object datasourceJsonToPython(nlohmann::json const& j) +{ + switch (j.type()) { + case nlohmann::json::value_t::null: + return py::none(); + case nlohmann::json::value_t::boolean: + return py::bool_(j.get()); + case nlohmann::json::value_t::number_integer: + return py::int_(j.get()); + case nlohmann::json::value_t::number_unsigned: + return py::int_(j.get()); + case nlohmann::json::value_t::number_float: + return py::float_(j.get()); + case nlohmann::json::value_t::string: + return py::str(j.get()); + case nlohmann::json::value_t::array: { + py::list result; + for (auto const& item : j) + result.append(datasourceJsonToPython(item)); + return result; + } + case nlohmann::json::value_t::object: { + py::dict result; + for (auto const& [key, value] : j.items()) + result[py::str(key)] = datasourceJsonToPython(value); + return result; + } + default: + mapget::raise("Unsupported JSON value type."); + } +} +} + void bindDataSourceServer(py::module_& m) { using namespace mapget; using namespace simfil; + py::enum_(m, "LayerType") + .value("FEATURES", LayerType::Features) + .value("HEIGHTMAP", LayerType::Heightmap) + .value("ORTHO_IMAGE", LayerType::OrthoImage) + .value("GLTF", LayerType::GLTF) + .value("SOURCE_DATA", LayerType::SourceData); + + py::class_(m, "MapTileKey") + .def(py::init<>(), "Construct an empty map tile key.") + .def(py::init(), py::arg("value"), "Parse a map tile key from its string form.") + .def(py::init(), + py::arg("layer_type"), + py::arg("map_id"), + py::arg("layer_id"), + py::arg("tile_id"), + py::arg("stage") = 0, + "Construct a map tile key from individual components.") + .def_readwrite("layer_type", &MapTileKey::layer_) + .def_readwrite("map_id", &MapTileKey::mapId_) + .def_readwrite("layer_id", &MapTileKey::layerId_) + .def_readwrite("tile_id", &MapTileKey::tileId_) + .def_readwrite("stage", &MapTileKey::stage_) + .def("to_string", &MapTileKey::toString, "Convert this key to its stable string form.") + .def("__str__", &MapTileKey::toString); + + py::class_(m, "LocateRequest") + .def(py::init([](std::string mapId, std::string typeId, KeyValuePairVec const& featureIdParts) { + return LocateRequest(std::move(mapId), std::move(typeId), castToKeyValue(castToKeyValueView(featureIdParts))); + }), + py::arg("map_id"), + py::arg("type_id"), + py::arg("feature_id_parts"), + "Construct a locate request for a feature id.") + .def(py::init([](py::dict const& dict) { + py::module jsonModule = py::module::import("json"); + auto jsonString = jsonModule.attr("dumps")(dict).cast(); + return LocateRequest(nlohmann::json::parse(jsonString)); + }), + py::arg("dict"), + "Construct a locate request from a Python dictionary.") + .def_readwrite("map_id", &LocateRequest::mapId_) + .def_readwrite("type_id", &LocateRequest::typeId_) + .def_property("feature_id_parts", + [](LocateRequest const& self) { + KeyValuePairVec result; + for (auto const& [key, value] : self.featureId_) { + std::visit( + [&result, &key](auto&& vv) { + result.emplace_back(key, vv); + }, + value); + } + return result; + }, + [](LocateRequest& self, KeyValuePairVec const& parts) { + self.setFeatureId(castToKeyValueView(parts)); + }) + .def("set_feature_id", [](LocateRequest& self, KeyValuePairVec const& parts) { + self.setFeatureId(castToKeyValueView(parts)); + }, + py::arg("feature_id_parts"), + "Replace the feature-id parts.") + .def("get_int_id_part", &LocateRequest::getIntIdPart, + py::arg("part_id"), + "Get an integer id part by name.") + .def("get_str_id_part", &LocateRequest::getStrIdPart, + py::arg("part_id"), + "Get a string id part by name.") + .def("to_dict", [](LocateRequest const& self) { + return datasourceJsonToPython(self.serialize()); + }, + "Serialize the request to a Python dictionary.") + .def("to_json", [](LocateRequest const& self) { return self.serialize().dump(); }, + "Serialize the request to a JSON string."); + + py::class_(m, "LocateResponse") + .def(py::init(), + py::arg("request"), + "Construct a response initialized from a request.") + .def(py::init([](py::dict const& dict) { + py::module jsonModule = py::module::import("json"); + auto jsonString = jsonModule.attr("dumps")(dict).cast(); + return LocateResponse(nlohmann::json::parse(jsonString)); + }), + py::arg("dict"), + "Construct a locate response from a Python dictionary.") + .def_readwrite("tile_key", &LocateResponse::tileKey_) + .def("to_dict", [](LocateResponse const& self) { + return datasourceJsonToPython(self.serialize()); + }, + "Serialize the response to a Python dictionary.") + .def("to_json", [](LocateResponse const& self) { return self.serialize().dump(); }, + "Serialize the response to a JSON string."); + py::class_>(m, "DataSourceServer") .def( py::init( @@ -34,13 +164,18 @@ void bindDataSourceServer(py::module_& m) }), R"pbdoc( Construct a DataSource with a DataSourceInfo metadata instance. - )pbdoc", + )pbdoc", py::arg("info_dict")) .def( "on_tile_feature_request", - &DataSourceServer::onTileFeatureRequest, + [](DataSourceServer& self, py::function callback) -> DataSourceServer& { + return self.onTileFeatureRequest( + [callback = std::move(callback)](TileFeatureLayer::Ptr tile) { + py::gil_scoped_acquire gil; + callback(std::move(tile)); + }); + }, py::arg("callback"), - py::call_guard(), R"pbdoc( Set the Callback which will be invoked when a `/tile`-request for a feature layer is received. @@ -51,16 +186,54 @@ void bindDataSourceServer(py::module_& m) )pbdoc") .def( "on_tile_sourcedata_request", - &DataSourceServer::onTileSourceDataRequest, + [](DataSourceServer& self, py::function callback) -> DataSourceServer& { + return self.onTileSourceDataRequest( + [callback = std::move(callback)](TileSourceDataLayer::Ptr tile) { + py::gil_scoped_acquire gil; + callback(std::move(tile)); + }); + }, py::arg("callback"), - py::call_guard(), R"pbdoc( Set the Callback which will be invoked when a `/tile`-request for a source-data layer is received. - The callback argument is a fresh TileFeatureLayer, which the callback must - fill according to the set TileFeatureLayer's layer info and tile id. If an + The callback argument is a fresh TileSourceDataLayer, which the callback must + fill according to the set TileSourceDataLayer's layer info and tile id. If an error occurs while filling the tile, the callback can use - TileFeatureLayer::setError(...) to signal the error downstream. + TileSourceDataLayer::setError(...) to signal the error downstream. + )pbdoc") + .def( + "on_locate_request", + [](DataSourceServer& self, py::function callback) -> DataSourceServer& { + return self.onLocateRequest( + [callback = std::move(callback)](LocateRequest const& request) { + py::gil_scoped_acquire gil; + return callback(request).cast>(); + }); + }, + py::arg("callback"), + R"pbdoc( + Set the Callback which will be invoked when a `/locate` request is received. + The callback receives a LocateRequest and must return a list of LocateResponse objects. + )pbdoc") + .def( + "on_cache_expired", + [](DataSourceServer& self, py::function callback) -> DataSourceServer& { + return self.onCacheExpired( + [callback = std::move(callback)]( + MapTileKey const& tileKey, + std::chrono::system_clock::time_point expiredAt) { + py::gil_scoped_acquire gil; + auto expiredAtUs = std::chrono::duration_cast( + expiredAt.time_since_epoch()).count(); + callback(tileKey, expiredAtUs); + }); + }, + py::arg("callback"), + R"pbdoc( + Set the callback invoked when a service reports that an expired cached + tile for this datasource is being refreshed. The callback receives + (MapTileKey, expired_at_unix_microseconds). )pbdoc") .def( "go", @@ -103,7 +276,7 @@ void bindDataSourceServer(py::module_& m) )pbdoc") .def( "info", - &DataSourceServer::info, + [](DataSourceServer& self) { return datasourceJsonToPython(self.info().toJson()); }, R"pbdoc( Get the DataSourceInfo metadata which this instance was constructed with. )pbdoc"); diff --git a/libs/pymapget/binding/py-layer.h b/libs/pymapget/binding/py-layer.h index 3d5bcaa7..fe4dca4f 100644 --- a/libs/pymapget/binding/py-layer.h +++ b/libs/pymapget/binding/py-layer.h @@ -1,6 +1,8 @@ #pragma once #include "mapget/model/featurelayer.h" +#include "mapget/model/sourcedata.h" +#include "mapget/model/sourcedatalayer.h" #include #include @@ -11,12 +13,173 @@ namespace py = pybind11; using namespace py::literals; +namespace mapget +{ + +struct BoundSourceDataCompound : public BoundModelNode +{ + model_ptr& ptr() { return *modelNodePtr_; } + model_ptr const& ptr() const { return *modelNodePtr_; } + + static void bind(py::module_& m) + { + py::class_(m, "SourceDataCompoundNode") + .def("schema_name", [](BoundSourceDataCompound& self) { + return self.ptr()->schemaName(); + }, + "Get the schema/type name associated with this source-data node.") + .def("set_schema_name", [](BoundSourceDataCompound& self, std::string_view const& name) { + self.ptr()->setSchemaName(name); + }, + py::arg("name"), + "Set the schema/type name associated with this source-data node.") + .def("source_data_address", [](BoundSourceDataCompound& self) { + return self.ptr()->sourceDataAddress(); + }, + "Get the source-data address associated with this node.") + .def("set_source_data_address", [](BoundSourceDataCompound& self, SourceDataAddress const& address) { + self.ptr()->setSourceDataAddress(address); + }, + py::arg("address"), + "Set the source-data address associated with this node.") + .def("add_field", [](BoundSourceDataCompound& self, + std::string_view const& name, + py::object const& pyValue) { + auto cppValue = pyValueToModel(pyValue, self.ptr()->model()); + auto object = self.ptr()->object(); + std::visit( + [&object, &name](auto&& value) { + if constexpr (std::is_same_v, bool>) + object->addBool(name, value); + else + object->addField(name, value); + }, + cppValue); + }, + py::arg("name"), + py::arg("value"), + "Add a field to this source-data compound object.") + .def("to_dict", [](BoundSourceDataCompound& self) { + return nodeToPython(self.node(), self.ptr()->model()); + }, + "Convert this source-data compound object to Python values."); + } + + ModelNode::Ptr node() override { return ModelNode::Ptr(ptr()); } + + explicit BoundSourceDataCompound(model_ptr ptr) + : modelNodePtr_(std::make_shared>(std::move(ptr))) + { + } + + std::shared_ptr> modelNodePtr_; +}; + +inline SourceDataAddress sourceDataAddressFromPython(py::handle value) +{ + if (py::isinstance(value)) { + return value.cast(); + } + return SourceDataAddress(value.cast()); +} + +inline model_ptr makeSourceDataReferences( + TileFeatureLayer& self, + py::iterable const& entries) +{ + std::vector refs; + for (auto const& item : entries) { + std::string layerId; + std::string qualifier; + SourceDataAddress address; + + if (py::isinstance(item)) { + auto dict = py::reinterpret_borrow(item); + py::object layerIdObject; + if (dict.contains("layer_id")) { + layerIdObject = dict["layer_id"]; + } else { + layerIdObject = dict["layerId"]; + } + layerId = py::str(layerIdObject); + qualifier = py::str(dict["qualifier"]); + address = sourceDataAddressFromPython(dict["address"]); + } else { + auto tuple = py::reinterpret_borrow(item); + if (tuple.size() != 3) { + throw py::value_error("source-data references must be dicts or (layer_id, qualifier, address) tuples"); + } + layerId = py::str(tuple[0]); + qualifier = py::str(tuple[1]); + address = sourceDataAddressFromPython(tuple[2]); + } + + auto layerStringId = self.strings()->emplace(layerId); + if (!layerStringId) + raise(layerStringId.error().message); + auto qualifierStringId = self.strings()->emplace(qualifier); + if (!qualifierStringId) + raise(qualifierStringId.error().message); + + refs.push_back(QualifiedSourceDataReference{ + address, + *layerStringId, + *qualifierStringId, + }); + } + + return self.newSourceDataReferenceCollection(std::span{refs}); +} + +} // namespace mapget + void bindTileLayer(py::module_& m) { using namespace mapget; using namespace simfil; - py::class_( + py::class_(m, "TileLayer") + .def("tile_id", &TileLayer::tileId, "Get the layer tile id.") + .def("map_id", &TileLayer::mapId, "Get the map id.") + .def("layer_id", [](TileLayer const& self) { return self.layerInfo()->layerId_; }, + "Get the layer id.") + .def("error", &TileLayer::error, "Get the tile error if one was set.") + .def("set_error", [](TileLayer& self, std::string const& e) { self.setError(e); }, + py::arg("err"), + "Set the tile error.") + .def("error_code", &TileLayer::errorCode, "Get the tile error code if one was set.") + .def("set_error_code", [](TileLayer& self, int code) { self.setErrorCode(code); }, + py::arg("code"), + "Set the tile error code.") + .def("ttl", [](TileLayer const& self) { return self.ttl() ? self.ttl()->count() : -1; }, + "Get the tile TTL in milliseconds, or -1 if unset.") + .def("set_ttl", [](TileLayer& self, int64_t ms) { + if (ms >= 0) + self.setTtl(std::chrono::milliseconds(ms)); + else + self.setTtl(std::nullopt); + }, + py::arg("time_to_live_in_ms"), + "Set the tile TTL in milliseconds, or -1 to clear it.") + .def("set_info", [](TileLayer& self, std::string const& key, simfil::ScalarValueType const& value) { + std::visit( + [&](auto&& vv) { + using V = std::decay_t; + if constexpr (std::is_same_v) { + return; + } else if constexpr (std::is_same_v) { + self.setInfo(key, vv.toHex()); + } else { + self.setInfo(key, vv); + } + }, + value); + }, + py::arg("key"), + py::arg("value"), + "Set a JSON metadata field on this tile."); + + py::class_( m, "TileFeatureLayer") .def( @@ -72,6 +235,20 @@ void bindTileLayer(py::module_& m) R"pbdoc( Get when this layer was created. )pbdoc") + .def( + "stage", + [](TileFeatureLayer const& self) { return self.stage(); }, + R"pbdoc( + Get the staged-loading index requested for this feature tile, or None + for unstaged requests. + )pbdoc") + .def( + "set_stage", + [](TileFeatureLayer& self, std::optional stage) { self.setStage(stage); }, + py::arg("stage") = std::nullopt, + R"pbdoc( + Set or clear the staged-loading index for this feature tile. + )pbdoc") .def( "ttl", [](TileFeatureLayer const& self) {return self.ttl() ? self.ttl()->count() : -1; }, @@ -165,14 +342,28 @@ void bindTileLayer(py::module_& m) )pbdoc") .def( "new_feature_id", - [](TileFeatureLayer& self, std::string const& typeId, KeyValuePairVec const& idParts) - { return BoundFeatureId(self.newFeatureId(typeId, castToKeyValueView(idParts))); }, + [](TileFeatureLayer& self, + std::string const& typeId, + KeyValuePairVec const& idParts, + std::optional const& mapId) + { + auto externalMapId = mapId + ? std::optional(*mapId) + : std::nullopt; + return BoundFeatureId( + self.newFeatureId( + typeId, + castToKeyValueView(idParts), + externalMapId)); + }, py::arg("type_id"), py::arg("feature_id_parts"), + py::arg("map_id") = py::none(), R"pbdoc( Create a new feature id. Use this function to create a reference to another feature. The created feature id will not use the common feature id prefix - from this tile feature layer. + from this tile feature layer. Pass `map_id` to reference a feature in + another map. )pbdoc") .def( "new_attribute", @@ -218,6 +409,33 @@ void bindTileLayer(py::module_& m) R"pbdoc( Create a new geometry of the given type. )pbdoc") + .def( + "new_relation", + [](TileFeatureLayer& self, std::string_view const& name, BoundFeatureId const& target) + { return BoundRelation(self.newRelation(name, target.modelNodePtr_)); }, + py::arg("name"), + py::arg("target"), + R"pbdoc( + Create a new relation object. Attach it to a feature with + Feature.add_relation(relation) or Feature.add_relation(name, target). + )pbdoc") + .def( + "new_validity_collection", + [](TileFeatureLayer& self, size_t initialCapacity) + { return BoundMultiValidity(self.newValidityCollection(initialCapacity)); }, + py::arg("initial_capacity") = 2, + R"pbdoc( + Create a new validity collection which can be attached to attributes or relations. + )pbdoc") + .def( + "new_source_data_references", + [](TileFeatureLayer& self, py::iterable const& entries) + { return BoundSourceDataReferenceCollection(makeSourceDataReferences(self, entries)); }, + py::arg("entries"), + R"pbdoc( + Create qualified source-data references from dictionaries or + (layer_id, qualifier, address) tuples. + )pbdoc") .def( "geojson", [](TileFeatureLayer& self) @@ -232,4 +450,29 @@ void bindTileLayer(py::module_& m) if (i < 0 || i >= sz) throw py::index_error(); return BoundFeature(self.at((size_t)i)); }); + + py::enum_(m, "SourceDataAddressFormat") + .value("UNKNOWN", TileSourceDataLayer::SourceDataAddressFormat::Unknown) + .value("BIT_RANGE", TileSourceDataLayer::SourceDataAddressFormat::BitRange); + + BoundSourceDataCompound::bind(m); + + py::class_(m, "TileSourceDataLayer") + .def("new_compound", [](TileSourceDataLayer& self, size_t initialSize) { + return BoundSourceDataCompound(self.newCompound(initialSize)); + }, + py::arg("initial_size") = 2, + "Create a new source-data compound node.") + .def("add_root", [](TileSourceDataLayer& self, BoundSourceDataCompound const& node) { + self.addRoot(ModelNode::Ptr(node.ptr())); + }, + py::arg("node"), + "Add a source-data compound node as a root of this layer.") + .def("source_data_address_format", &TileSourceDataLayer::sourceDataAddressFormat, + "Get the source-data address format.") + .def("set_source_data_address_format", &TileSourceDataLayer::setSourceDataAddressFormat, + py::arg("format"), + "Set the source-data address format.") + .def("to_json", [](TileSourceDataLayer& self) { return self.toJson().dump(); }, + "Convert this source-data layer to JSON."); } diff --git a/libs/pymapget/binding/py-model.h b/libs/pymapget/binding/py-model.h index a78d9399..5beed6d3 100644 --- a/libs/pymapget/binding/py-model.h +++ b/libs/pymapget/binding/py-model.h @@ -4,6 +4,7 @@ #include "mapget/model/feature.h" #include "mapget/model/featurelayer.h" #include "simfil/value.h" +#include "mapget/model/sourcedatareference.h" #include #include @@ -16,7 +17,7 @@ using namespace simfil; namespace mapget { -py::object nodeToPython(model_ptr const& n, TileFeatureLayer& fl, bool checkMultimap = false); +py::object nodeToPython(model_ptr const& n, simfil::ModelPool& model, bool checkMultimap = false); struct BoundModelNode { @@ -152,7 +153,7 @@ struct BoundModelNodeBase : public BoundModelNode using ModelVariant = std::variant; -ModelVariant pyValueToModel(py::object const& pyValue, TileFeatureLayer& model) +ModelVariant pyValueToModel(py::object const& pyValue, simfil::ModelPool& model) { if (py::isinstance(pyValue)) { return pyValue.cast(); @@ -377,7 +378,16 @@ struct BoundGeometry : public BoundModelNode return self.modelNodePtr_->pointAt(i); }) .def("length", [](BoundGeometry& self) { return self.modelNodePtr_->length(); }, - "Get total length in metres (for polylines)."); + "Get total length in metres (for polylines).") + .def("stage", [](BoundGeometry& self) { + return self.modelNodePtr_->stage(); + }, + "Get the geometry stage, or None if no stage is set.") + .def("set_stage", [](BoundGeometry& self, std::optional stage) { + self.modelNodePtr_->setStage(stage); + }, + py::arg("stage") = std::nullopt, + "Set or clear the geometry stage."); } ModelNode::Ptr node() override { return modelNodePtr_; } @@ -421,27 +431,82 @@ struct BoundGeometryCollection : public BoundModelNode model_ptr modelNodePtr_; }; +struct BoundValidity : public BoundModelNode +{ + static void bind(py::module_& m); + ModelNode::Ptr node() override; + explicit BoundValidity(model_ptr const& ptr); + model_ptr modelNodePtr_; +}; + +struct BoundMultiValidity : public BoundModelNode +{ + static void bind(py::module_& m); + ModelNode::Ptr node() override; + explicit BoundMultiValidity(model_ptr const& ptr); + model_ptr modelNodePtr_; +}; + +struct BoundSourceDataReferenceCollection : public BoundModelNode +{ + static void bind(py::module_& m); + ModelNode::Ptr node() override; + explicit BoundSourceDataReferenceCollection(model_ptr const& ptr); + model_ptr modelNodePtr_; +}; + +struct BoundRelation : public BoundModelNode +{ + static void bind(py::module_& m); + ModelNode::Ptr node() override; + explicit BoundRelation(model_ptr const& ptr); + model_ptr modelNodePtr_; +}; + struct BoundAttribute : public BoundObject { static void bind(py::module_& m) { - py::enum_(m, "Direction") - .value("EMPTY", Validity::Direction::Empty) - .value("POSITIVE", Validity::Direction::Positive) - .value("NEGATIVE", Validity::Direction::Negative) - .value("COMPLETE", Validity::Direction::Both) - .value("NONE", Validity::Direction::None); - auto boundClass = py::class_(m, "Attribute") .def( "validity", - [](BoundAttribute& self) { return self.modelNodePtr_->validityOrNull(); }, - "Get the validity of the attribute.") + [](BoundAttribute& self) { return BoundMultiValidity(self.modelNodePtr_->validity()); }, + "Get or create the attribute validity collection.") + .def( + "validity_or_none", + [](BoundAttribute& self) -> py::object { + if (auto validity = self.modelNodePtr_->validityOrNull()) + return py::cast(BoundMultiValidity(validity)); + return py::none(); + }, + "Get the attribute validity collection if present.") + .def( + "set_validity", + [](BoundAttribute& self, BoundMultiValidity const& validity) { + self.modelNodePtr_->setValidity(validity.modelNodePtr_); + }, + py::arg("validity"), + "Assign an existing validity collection to this attribute.") .def( "name", [](BoundAttribute& self) { return self.modelNodePtr_->name(); }, - "Get the name of the attribute."); + "Get the name of the attribute.") + .def( + "source_data_references", + [](BoundAttribute& self) -> py::object { + if (auto refs = self.modelNodePtr_->sourceDataReferences()) + return py::cast(BoundSourceDataReferenceCollection(refs)); + return py::none(); + }, + "Get source-data references attached to this attribute.") + .def( + "set_source_data_references", + [](BoundAttribute& self, BoundSourceDataReferenceCollection const& refs) { + self.modelNodePtr_->setSourceDataReferences(refs.modelNodePtr_); + }, + py::arg("refs"), + "Attach source-data references to this attribute."); bindObjectMethods(boundClass); boundClass.def("__iter__", [](BoundAttribute& self) { @@ -565,7 +630,33 @@ struct BoundFeatureId : public BoundModelNode .def( "type_id", [](BoundFeatureId& self) { return self.modelNodePtr_->typeId(); }, - "Get the feature ID's type ID."); + "Get the feature ID's type ID.") + .def( + "map_id", + [](BoundFeatureId& self) { return self.modelNodePtr_->mapId(); }, + "Get the effective map ID referenced by this feature ID.") + .def( + "external_map_id", + [](BoundFeatureId& self) { return self.modelNodePtr_->externalMapId(); }, + "Get the explicitly stored external map ID, or None for local references.") + .def( + "key_value_pairs", + [](BoundFeatureId& self) { + KeyValuePairVec result; + for (auto const& [key, value] : self.modelNodePtr_->keyValuePairs()) { + std::visit( + [&result, &key](auto&& vv) { + using V = std::decay_t; + if constexpr (std::is_same_v) + result.emplace_back(std::string(key), std::string(vv)); + else + result.emplace_back(std::string(key), vv); + }, + value); + } + return result; + }, + "Get feature-id parts as name/value pairs."); } ModelNode::Ptr node() override { return modelNodePtr_; } @@ -606,10 +697,92 @@ struct BoundFeature : public BoundModelNode [](BoundFeature& self) { return BoundAttributeLayerList(self.modelNodePtr_->attributeLayers()); }, "Access this feature's attribute layer collection.") + .def( + "lod", + [](BoundFeature& self) { + return static_cast(self.modelNodePtr_->lod()); + }, + "Get this feature's level-of-detail value as an integer in [0, 7].") + .def( + "set_lod", + [](BoundFeature& self, uint32_t lod) { + if (lod > static_cast(Feature::MAX_LOD)) + throw py::value_error("Feature LOD must be in the range [0, 7]."); + self.modelNodePtr_->setLod(static_cast(lod)); + }, + py::arg("lod"), + "Set this feature's level-of-detail value as an integer in [0, 7].") .def( "relations", - [](BoundFeature& self) { return BoundArray(self.modelNodePtr_->relations()); }, - "Access this feature's relation list.") + [](BoundFeature& self) { + py::list result; + self.modelNodePtr_->forEachRelation( + [&result](model_ptr const& relation) { + result.append(BoundRelation(relation)); + return true; + }); + return result; + }, + "Get this feature's relations as typed Relation objects.") + .def( + "num_relations", + [](BoundFeature& self) { return self.modelNodePtr_->numRelations(); }, + "Get the number of relations attached to this feature.") + .def( + "relation_at", + [](BoundFeature& self, int64_t i) { + auto sz = (int64_t)self.modelNodePtr_->numRelations(); + if (i < 0) i += sz; + if (i < 0 || i >= sz) throw py::index_error(); + return BoundRelation(self.modelNodePtr_->getRelation((uint32_t)i)); + }, + py::arg("index"), + "Get a typed Relation at the given index.") + .def( + "add_relation", + [](BoundFeature& self, std::string_view const& name, BoundFeatureId const& target) { + return BoundRelation(self.modelNodePtr_->addRelation(name, target.modelNodePtr_)); + }, + py::arg("name"), + py::arg("target"), + "Create and attach a named relation to an existing target FeatureId.") + .def( + "add_relation", + [](BoundFeature& self, BoundRelation const& relation) { + return BoundRelation(self.modelNodePtr_->addRelation(relation.modelNodePtr_)); + }, + py::arg("relation"), + "Attach an existing Relation object to this feature.") + .def( + "add_relation", + [](BoundFeature& self, + std::string_view const& name, + std::string_view const& targetType, + KeyValuePairVec const& targetIdParts) { + return BoundRelation(self.modelNodePtr_->addRelation( + name, + targetType, + castToKeyValueView(targetIdParts))); + }, + py::arg("name"), + py::arg("target_type"), + py::arg("target_id_parts"), + "Create and attach a named relation by target type and id parts.") + .def( + "source_data_references", + [](BoundFeature& self) -> py::object { + if (auto refs = self.modelNodePtr_->sourceDataReferences()) + return py::cast(BoundSourceDataReferenceCollection(refs)); + return py::none(); + }, + "Get source-data references attached to this feature.") + .def( + "set_source_data_references", + [](BoundFeature& self, BoundSourceDataReferenceCollection const& refs) { + self.modelNodePtr_->setSourceDataReferences(refs.modelNodePtr_); + }, + py::arg("refs"), + "Attach source-data references to this feature.") .def( "add_point", [](BoundFeature& self, Point const& p) { @@ -654,6 +827,345 @@ struct BoundFeature : public BoundModelNode model_ptr modelNodePtr_; }; +inline BoundValidity::BoundValidity(model_ptr const& ptr) : modelNodePtr_(ptr) {} + +inline ModelNode::Ptr BoundValidity::node() { return modelNodePtr_; } + +inline void BoundValidity::bind(py::module_& m) +{ + py::enum_(m, "Direction") + .value("EMPTY", Validity::Direction::Empty) + .value("POSITIVE", Validity::Direction::Positive) + .value("NEGATIVE", Validity::Direction::Negative) + .value("COMPLETE", Validity::Direction::Both) + .value("NONE", Validity::Direction::None); + + py::enum_(m, "ValidityGeometryDescriptionType") + .value("NO_GEOMETRY", Validity::NoGeometry) + .value("SIMPLE_GEOMETRY", Validity::SimpleGeometry) + .value("OFFSET_POINT", Validity::OffsetPointValidity) + .value("OFFSET_RANGE", Validity::OffsetRangeValidity) + .value("FEATURE_TRANSITION", Validity::FeatureTransition); + + py::enum_(m, "ValidityGeometryOffsetType") + .value("INVALID", Validity::InvalidOffsetType) + .value("GEO_POSITION", Validity::GeoPosOffset) + .value("BUFFER", Validity::BufferOffset) + .value("RELATIVE_LENGTH", Validity::RelativeLengthOffset) + .value("METRIC_LENGTH", Validity::MetricLengthOffset); + + py::enum_(m, "TransitionEnd") + .value("START", Validity::Start) + .value("END", Validity::End); + + py::class_(m, "Validity") + .def("direction", [](BoundValidity& self) { return self.modelNodePtr_->direction(); }, + "Get the direction in which this validity applies.") + .def("set_direction", [](BoundValidity& self, Validity::Direction direction) { + self.modelNodePtr_->setDirection(direction); + }, + py::arg("direction"), + "Set the direction in which this validity applies.") + .def("geometry_description_type", [](BoundValidity& self) { + return self.modelNodePtr_->geometryDescriptionType(); + }, + "Get the kind of geometry restriction stored by this validity.") + .def("geometry_offset_type", [](BoundValidity& self) { + return self.modelNodePtr_->geometryOffsetType(); + }, + "Get the offset interpretation used by point/range validities.") + .def("geometry_stage", [](BoundValidity& self) { + return self.modelNodePtr_->geometryStage(); + }, + "Get the referenced geometry stage if one is set.") + .def("set_geometry_stage", [](BoundValidity& self, std::optional stage) { + self.modelNodePtr_->setGeometryStage(stage); + }, + py::arg("stage"), + "Set or clear the referenced geometry stage.") + .def("feature_id", [](BoundValidity& self) -> py::object { + if (auto featureId = self.modelNodePtr_->featureId()) + return py::cast(BoundFeatureId(featureId)); + return py::none(); + }, + "Get the feature-id referenced by this validity, if present.") + .def("set_feature_id", [](BoundValidity& self, BoundFeatureId const& featureId) { + self.modelNodePtr_->setFeatureId(featureId.modelNodePtr_); + }, + py::arg("feature_id"), + "Reference another feature's geometry for this validity.") + .def("offset_point", [](BoundValidity& self) { + return self.modelNodePtr_->offsetPoint(); + }, + "Get the stored offset point if this is a point validity.") + .def("set_offset_point", [](BoundValidity& self, Point const& point) { + self.modelNodePtr_->setOffsetPoint(point); + }, + py::arg("point"), + "Set this validity to a geographic point restriction.") + .def("set_offset_point", [](BoundValidity& self, Validity::GeometryOffsetType offsetType, double point) { + self.modelNodePtr_->setOffsetPoint(offsetType, point); + }, + py::arg("offset_type"), + py::arg("point"), + "Set this validity to a one-dimensional point offset restriction.") + .def("offset_range", [](BoundValidity& self) { + return self.modelNodePtr_->offsetRange(); + }, + "Get the stored offset range if this is a range validity.") + .def("set_offset_range", [](BoundValidity& self, Point const& start, Point const& end) { + self.modelNodePtr_->setOffsetRange(start, end); + }, + py::arg("start"), + py::arg("end"), + "Set this validity to a geographic range restriction.") + .def("set_offset_range", [](BoundValidity& self, + Validity::GeometryOffsetType offsetType, + double start, + double end) { + self.modelNodePtr_->setOffsetRange(offsetType, start, end); + }, + py::arg("offset_type"), + py::arg("start"), + py::arg("end"), + "Set this validity to a one-dimensional range offset restriction.") + .def("simple_geometry", [](BoundValidity& self) -> py::object { + if (auto geom = self.modelNodePtr_->simpleGeometry()) + return py::cast(BoundGeometry(geom)); + return py::none(); + }, + "Get the explicit geometry stored by this validity, if present.") + .def("set_simple_geometry", [](BoundValidity& self, BoundGeometry const& geometry) { + self.modelNodePtr_->setSimpleGeometry(geometry.modelNodePtr_); + }, + py::arg("geometry"), + "Store an explicit geometry as this validity's restriction.") + .def("transition_number", [](BoundValidity& self) { + return self.modelNodePtr_->transitionNumber(); + }, + "Get the semantic transition number, if this validity is a transition.") + .def("transition_from_connected_end", [](BoundValidity& self) { + return self.modelNodePtr_->transitionFromConnectedEnd(); + }, + "Get the connected endpoint of the transition source feature.") + .def("transition_to_connected_end", [](BoundValidity& self) { + return self.modelNodePtr_->transitionToConnectedEnd(); + }, + "Get the connected endpoint of the transition target feature."); +} + +inline BoundMultiValidity::BoundMultiValidity(model_ptr const& ptr) : modelNodePtr_(ptr) {} + +inline ModelNode::Ptr BoundMultiValidity::node() { return modelNodePtr_; } + +inline void BoundMultiValidity::bind(py::module_& m) +{ + py::class_(m, "MultiValidity") + .def("new_point", [](BoundMultiValidity& self, + Point const& point, + std::optional geometryStage, + Validity::Direction direction) { + return BoundValidity(self.modelNodePtr_->newPoint(point, geometryStage, direction)); + }, + py::arg("point"), + py::arg("geometry_stage") = std::nullopt, + py::arg("direction") = Validity::Empty, + "Append a geographic point validity.") + .def("new_offset_point", [](BoundMultiValidity& self, + Validity::GeometryOffsetType offsetType, + double point, + std::optional geometryStage, + Validity::Direction direction) { + return BoundValidity(self.modelNodePtr_->newPoint(offsetType, point, geometryStage, direction)); + }, + py::arg("offset_type"), + py::arg("point"), + py::arg("geometry_stage") = std::nullopt, + py::arg("direction") = Validity::Empty, + "Append a one-dimensional point-offset validity.") + .def("new_range", [](BoundMultiValidity& self, + Point const& start, + Point const& end, + std::optional geometryStage, + Validity::Direction direction) { + return BoundValidity(self.modelNodePtr_->newRange(start, end, geometryStage, direction)); + }, + py::arg("start"), + py::arg("end"), + py::arg("geometry_stage") = std::nullopt, + py::arg("direction") = Validity::Empty, + "Append a geographic range validity.") + .def("new_offset_range", [](BoundMultiValidity& self, + Validity::GeometryOffsetType offsetType, + double start, + double end, + std::optional geometryStage, + Validity::Direction direction) { + return BoundValidity(self.modelNodePtr_->newRange(offsetType, start, end, geometryStage, direction)); + }, + py::arg("offset_type"), + py::arg("start"), + py::arg("end"), + py::arg("geometry_stage") = std::nullopt, + py::arg("direction") = Validity::Empty, + "Append a one-dimensional range-offset validity.") + .def("new_geometry", [](BoundMultiValidity& self, + BoundGeometry const& geometry, + Validity::Direction direction) { + return BoundValidity(self.modelNodePtr_->newGeometry(geometry.modelNodePtr_, direction)); + }, + py::arg("geometry"), + py::arg("direction") = Validity::Empty, + "Append a validity that stores an explicit geometry.") + .def("new_feature_id", [](BoundMultiValidity& self, + BoundFeatureId const& featureId, + Validity::Direction direction) { + return BoundValidity(self.modelNodePtr_->newFeatureId(featureId.modelNodePtr_, direction)); + }, + py::arg("feature_id"), + py::arg("direction") = Validity::Empty, + "Append a validity that references another feature's full geometry.") + .def("new_geom_stage", [](BoundMultiValidity& self, + uint32_t geometryStage, + Validity::Direction direction) { + return BoundValidity(self.modelNodePtr_->newGeomStage(geometryStage, direction)); + }, + py::arg("geometry_stage"), + py::arg("direction") = Validity::Empty, + "Append a validity that references one staged geometry.") + .def("new_complete", [](BoundMultiValidity& self, Validity::Direction direction) { + return BoundValidity(self.modelNodePtr_->newComplete(direction)); + }, + py::arg("direction") = Validity::Empty, + "Append a validity covering the complete referenced geometry.") + .def("new_direction", [](BoundMultiValidity& self, Validity::Direction direction) { + return BoundValidity(self.modelNodePtr_->newDirection(direction)); + }, + py::arg("direction") = Validity::Empty, + "Append a direction-only validity.") + .def("__len__", [](BoundMultiValidity& self) { return self.modelNodePtr_->size(); }) + .def("__getitem__", [](BoundMultiValidity& self, int64_t i) { + auto sz = (int64_t)self.modelNodePtr_->size(); + if (i < 0) i += sz; + if (i < 0 || i >= sz) throw py::index_error(); + auto node = self.modelNodePtr_->at(i); + BoundModelNodeBase base; + base.modelNodePtr_ = node; + return BoundValidity(base.featureLayer().resolve(*node)); + }) + .def("__iter__", [](BoundMultiValidity& self) { + py::list result; + for (auto i = 0u; i < self.modelNodePtr_->size(); ++i) { + auto node = self.modelNodePtr_->at(i); + BoundModelNodeBase base; + base.modelNodePtr_ = node; + result.append(BoundValidity(base.featureLayer().resolve(*node))); + } + return py::iter(result); + }); +} + +inline BoundSourceDataReferenceCollection::BoundSourceDataReferenceCollection( + model_ptr const& ptr) + : modelNodePtr_(ptr) +{ +} + +inline ModelNode::Ptr BoundSourceDataReferenceCollection::node() { return modelNodePtr_; } + +inline void BoundSourceDataReferenceCollection::bind(py::module_& m) +{ + py::class_(m, "SourceDataAddress") + .def(py::init<>(), "Construct an empty source-data address.") + .def(py::init(), + py::arg("bit_offset"), + py::arg("bit_size"), + "Construct a source-data address from bit offset and bit size.") + .def(py::init(), py::arg("value"), "Construct a source-data address from its packed integer.") + .def("value", &SourceDataAddress::u64, "Get the packed 64-bit address value.") + .def("bit_offset", &SourceDataAddress::bitOffset, "Get the bit offset.") + .def("bit_size", &SourceDataAddress::bitSize, "Get the bit size."); + + py::class_(m, "SourceDataReferenceCollection") + .def("__len__", [](BoundSourceDataReferenceCollection& self) { return self.modelNodePtr_->size(); }) + .def("__iter__", [](BoundSourceDataReferenceCollection& self) { + py::list result; + self.modelNodePtr_->forEachReference([&result](SourceDataReferenceItem const& item) { + py::dict entry; + entry["layer_id"] = py::str(std::string(item.layerId())); + entry["qualifier"] = py::str(std::string(item.qualifier())); + entry["address"] = item.address(); + result.append(entry); + }); + return py::iter(result); + }) + .def("to_list", [](BoundSourceDataReferenceCollection& self) { + py::list result; + self.modelNodePtr_->forEachReference([&result](SourceDataReferenceItem const& item) { + py::dict entry; + entry["layer_id"] = py::str(std::string(item.layerId())); + entry["qualifier"] = py::str(std::string(item.qualifier())); + entry["address"] = py::int_(item.address().u64()); + result.append(entry); + }); + return result; + }, "Convert references to Python dictionaries with packed integer addresses."); +} + +inline BoundRelation::BoundRelation(model_ptr const& ptr) : modelNodePtr_(ptr) {} + +inline ModelNode::Ptr BoundRelation::node() { return modelNodePtr_; } + +inline void BoundRelation::bind(py::module_& m) +{ + py::class_(m, "Relation") + .def("name", [](BoundRelation& self) { return self.modelNodePtr_->name(); }, + "Get the relation name.") + .def("target", [](BoundRelation& self) { return BoundFeatureId(self.modelNodePtr_->target()); }, + "Get the target feature id.") + .def("source_validity", [](BoundRelation& self) { + return BoundMultiValidity(self.modelNodePtr_->sourceValidity()); + }, + "Get or create the source-side validity collection.") + .def("source_validity_or_none", [](BoundRelation& self) -> py::object { + if (auto validity = self.modelNodePtr_->sourceValidityOrNull()) + return py::cast(BoundMultiValidity(validity)); + return py::none(); + }, + "Get the source-side validity collection if present.") + .def("set_source_validity", [](BoundRelation& self, BoundMultiValidity const& validity) { + self.modelNodePtr_->setSourceValidity(validity.modelNodePtr_); + }, + py::arg("validity"), + "Assign an existing source-side validity collection.") + .def("target_validity", [](BoundRelation& self) { + return BoundMultiValidity(self.modelNodePtr_->targetValidity()); + }, + "Get or create the target-side validity collection.") + .def("target_validity_or_none", [](BoundRelation& self) -> py::object { + if (auto validity = self.modelNodePtr_->targetValidityOrNull()) + return py::cast(BoundMultiValidity(validity)); + return py::none(); + }, + "Get the target-side validity collection if present.") + .def("set_target_validity", [](BoundRelation& self, BoundMultiValidity const& validity) { + self.modelNodePtr_->setTargetValidity(validity.modelNodePtr_); + }, + py::arg("validity"), + "Assign an existing target-side validity collection.") + .def("source_data_references", [](BoundRelation& self) -> py::object { + if (auto refs = self.modelNodePtr_->sourceDataReferences()) + return py::cast(BoundSourceDataReferenceCollection(refs)); + return py::none(); + }, + "Get source-data references attached to this relation.") + .def("set_source_data_references", [](BoundRelation& self, BoundSourceDataReferenceCollection const& refs) { + self.modelNodePtr_->setSourceDataReferences(refs.modelNodePtr_); + }, + py::arg("refs"), + "Attach source-data references to this relation."); +} + /// Recursively convert a ModelNode tree to native Python objects. /// Mirrors simfil's ModelNode::toJson() (nodes.cpp) but builds /// py::dict/py::list/scalars directly, avoiding JSON serialization. @@ -664,7 +1176,7 @@ struct BoundFeature : public BoundModelNode /// marker. Only AttributeLayer-level objects can have duplicates, /// so callers should only pass true at that level to avoid overhead. /// - ByteArray scalars: converted to {"_bytes": True, "hex": ..., "number": ...}. -py::object nodeToPython(model_ptr const& n, TileFeatureLayer& fl, bool checkMultimap) +py::object nodeToPython(model_ptr const& n, simfil::ModelPool& model, bool checkMultimap) { auto type = n->type(); if (type == ValueType::Object) { @@ -691,12 +1203,12 @@ py::object nodeToPython(model_ptr const& n, TileFeatureLayer& fl, boo } if (isMultiMap) { for (auto const& [fieldId, child] : n->fields()) { - if (auto key = fl.lookupStringId(fieldId)) { + if (auto key = model.lookupStringId(fieldId)) { auto pyKey = py::str(std::string(*key)); if (d.contains(pyKey)) - d[pyKey].cast().append(nodeToPython(child, fl)); + d[pyKey].cast().append(nodeToPython(child, model)); else - d[pyKey] = py::list(py::make_tuple(nodeToPython(child, fl))); + d[pyKey] = py::list(py::make_tuple(nodeToPython(child, model))); } } d[py::str("_multimap")] = py::bool_(true); @@ -704,15 +1216,15 @@ py::object nodeToPython(model_ptr const& n, TileFeatureLayer& fl, boo } } for (auto const& [fieldId, child] : n->fields()) { - if (auto key = fl.lookupStringId(fieldId)) - d[py::str(std::string(*key))] = nodeToPython(child, fl); + if (auto key = model.lookupStringId(fieldId)) + d[py::str(std::string(*key))] = nodeToPython(child, model); } return d; } if (type == ValueType::Array) { py::list l; for (uint32_t i = 0; i < n->size(); ++i) - l.append(nodeToPython(n->at(i), fl)); + l.append(nodeToPython(n->at(i), model)); return l; } auto v = n->value(); @@ -752,9 +1264,13 @@ void bindModel(py::module& m) mapget::BoundArray::bind(m); mapget::BoundGeometry::bind(m); mapget::BoundGeometryCollection::bind(m); + mapget::BoundFeatureId::bind(m); + mapget::BoundValidity::bind(m); + mapget::BoundMultiValidity::bind(m); + mapget::BoundSourceDataReferenceCollection::bind(m); mapget::BoundAttribute::bind(m); mapget::BoundAttributeLayer::bind(m); mapget::BoundAttributeLayerList::bind(m); - mapget::BoundFeatureId::bind(m); + mapget::BoundRelation::bind(m); mapget::BoundFeature::bind(m); } diff --git a/libs/service/include/mapget/service/config.h b/libs/service/include/mapget/service/config.h index fbc27f05..4d005982 100644 --- a/libs/service/include/mapget/service/config.h +++ b/libs/service/include/mapget/service/config.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,13 @@ namespace mapget { +struct DataSourceConfigStats { + size_t configured = 0; + size_t enabled = 0; + size_t disabled = 0; + size_t constructionFailed = 0; +}; + /** * Singleton class that watches a particular YAML config file path. @@ -32,6 +40,9 @@ namespace mapget class DataSourceConfigService { public: + using PublicConfigSectionSerializer = + std::function; + /** * Gets the singleton instance of the DataSourceConfig class. * @return Reference to the singleton instance. @@ -124,6 +135,23 @@ class DataSourceConfigService /** Top-level JSON keys allowed by current schema (properties keys). */ [[nodiscard]] std::vector topLevelDataSourceConfigKeys() const; + /** Latest datasource config statistics from the most recent load. */ + [[nodiscard]] DataSourceConfigStats getDataSourceConfigStats() const; + + /** + * Register an additional public top-level config section for GET /config. + * The serializer receives the full YAML config document. + */ + void registerPublicConfigSection( + std::string name, + PublicConfigSectionSerializer serializer); + + /** + * Serialize registered public config sections. + * Every registered key is present in the result. + */ + [[nodiscard]] nlohmann::json getPublicConfigSections(YAML::Node const& fullConfig) const; + /** * Call this to stop the config file watching thread. */ @@ -185,6 +213,8 @@ class DataSourceConfigService std::optional schemaPatch_; mutable std::optional schema_; mutable std::unique_ptr validator_; + std::map publicConfigSectionSerializers_; + DataSourceConfigStats dataSourceConfigStats_; // Atomic flag to control the file watching thread. std::atomic watching_ = false; @@ -203,6 +233,14 @@ nlohmann::json yamlToJson( std::unordered_map* maskedSecretMap = nullptr, bool maskCurrentNode = false); +/** Parse a YAML or JSON document from a string buffer into JSON. */ +[[nodiscard]] nlohmann::json parseStructuredDocument( + std::string_view content, + std::string_view sourceName = ""); + +/** Load and parse a YAML or JSON document from disk into JSON. */ +[[nodiscard]] nlohmann::json loadStructuredDocumentFile(std::string const& path); + /** Convert JSON to YAML, resolving masked secrets if provided. */ YAML::Node jsonToYaml( const nlohmann::json& json, diff --git a/libs/service/include/mapget/service/service.h b/libs/service/include/mapget/service/service.h index 6d1e1563..e1a7d9ea 100644 --- a/libs/service/include/mapget/service/service.h +++ b/libs/service/include/mapget/service/service.h @@ -24,8 +24,18 @@ enum class RequestStatus { Aborted = 0x4 /** Canceled, e.g. because a bundled request cannot be fulfilled. */ }; +enum class NoDataSourceReason { + None = 0x0, + EmptySources = 0x1, + AllSourcesDisabled = 0x2, + DatasourceInitializationFailed = 0x3, + MissingMapOrLayer = 0x4, + NoConfig = 0x5, +}; + struct LayerRequestContext { RequestStatus status_ = RequestStatus::NoDataSource; + NoDataSourceReason noDataSourceReason_ = NoDataSourceReason::MissingMapOrLayer; LayerType layerType_ = LayerType::Features; uint32_t stages_ = 1; }; diff --git a/libs/service/src/config.cpp b/libs/service/src/config.cpp index 378f745b..40e3cce1 100644 --- a/libs/service/src/config.cpp +++ b/libs/service/src/config.cpp @@ -6,7 +6,7 @@ #include #include #include -#include +#include #include #include @@ -37,6 +37,15 @@ nlohmann::json authHeaderSchema() }; } +nlohmann::json enabledSchema() +{ + return { + {"type", "boolean"}, + {"title", "Enabled"}, + {"description", "If false, this datasource entry is skipped."} + }; +} + } // namespace DataSourceConfigService& DataSourceConfigService::get() @@ -130,9 +139,20 @@ void DataSourceConfigService::loadConfig() std::lock_guard memberAccessLock(memberAccessMutex_); validateDataSourceConfig(config); currentConfig_.clear(); + dataSourceConfigStats_ = {}; if (auto sourcesNode = config["sources"]) { for (auto const& node : sourcesNode) + { + ++dataSourceConfigStats_.configured; + const bool enabled = !node["enabled"].IsDefined() || node["enabled"].as(true); + if (enabled) { + ++dataSourceConfigStats_.enabled; + } + else { + ++dataSourceConfigStats_.disabled; + } currentConfig_.push_back(node); + } } else { log().debug(fmt::format("The config file {} does not have a sources node.", configFilePath_)); @@ -165,6 +185,18 @@ void DataSourceConfigService::loadConfig() DataSource::Ptr DataSourceConfigService::makeDataSource(YAML::Node const& descriptor) { + try { + if (auto enabledNode = descriptor["enabled"]; + enabledNode.IsDefined() && !enabledNode.as(true)) + { + return nullptr; + } + } + catch (std::exception const& e) { + log().error("Invalid datasource `enabled` value: {}", e.what()); + return nullptr; + } + if (auto typeNode = descriptor["type"]) { std::lock_guard memberAccessLock(memberAccessMutex_); auto type = typeNode.as(); @@ -278,6 +310,53 @@ nlohmann::json yamlToJson( return {}; } +nlohmann::json parseStructuredDocument( + std::string_view content, + std::string_view sourceName) +{ + auto trimmed = content; + while (!trimmed.empty() && std::isspace(static_cast(trimmed.front()))) + trimmed.remove_prefix(1); + while (!trimmed.empty() && std::isspace(static_cast(trimmed.back()))) + trimmed.remove_suffix(1); + + if (trimmed.empty()) { + raise(fmt::format("Structured document `{}` is empty.", sourceName)); + } + + if (trimmed.front() == '{' || trimmed.front() == '[') { + try { + return nlohmann::json::parse(trimmed); + } + catch (const std::exception&) { + // Fall back to YAML parsing below. YAML is a superset of JSON, + // so this keeps error handling consistent for malformed input. + } + } + + try { + return yamlToJson(YAML::Load(std::string(content)), false); + } + catch (const YAML::Exception& e) { + raise(fmt::format( + "Failed to parse structured document `{}` as YAML or JSON: {}", + sourceName, + e.what())); + } +} + +nlohmann::json loadStructuredDocumentFile(std::string const& path) +{ + std::ifstream file(path); + if (!file) { + raise(fmt::format("Failed to open structured document file `{}`.", path)); + } + + std::ostringstream buffer; + buffer << file.rdbuf(); + return parseStructuredDocument(buffer.str(), path); +} + YAML::Node jsonToYaml( const nlohmann::json& json, const std::unordered_map& maskedSecretMap) @@ -365,6 +444,8 @@ nlohmann::json DataSourceConfigService::getDataSourceConfigSchema() const {"enum", nlohmann::json::array({typeName})} }; + if (!properties.contains("enabled")) + properties["enabled"] = enabledSchema(); if (!properties.contains("ttl")) properties["ttl"] = ttlSchema(); if (!properties.contains("auth-header")) @@ -388,6 +469,7 @@ nlohmann::json DataSourceConfigService::getDataSourceConfigSchema() const {"type", "object"}, {"properties", { {"type", typeProperty}, + {"enabled", enabledSchema()}, {"ttl", ttlSchema()}, {"auth-header", authHeaderSchema()} }}, @@ -443,6 +525,67 @@ std::vector DataSourceConfigService::topLevelDataSourceConfigKeys() return keys; } +DataSourceConfigStats DataSourceConfigService::getDataSourceConfigStats() const +{ + std::lock_guard memberAccessLock(memberAccessMutex_); + return dataSourceConfigStats_; +} + +void DataSourceConfigService::registerPublicConfigSection( + std::string name, + PublicConfigSectionSerializer serializer) +{ + if (name.empty()) { + log().warn("Refusing to register public config section with empty name."); + return; + } + if (!serializer) { + log().warn("Refusing to register public config section {} with NULL serializer.", name); + return; + } + + std::lock_guard memberAccessLock(memberAccessMutex_); + publicConfigSectionSerializers_[std::move(name)] = std::move(serializer); +} + +nlohmann::json DataSourceConfigService::getPublicConfigSections(YAML::Node const& fullConfig) const +{ + std::lock_guard memberAccessLock(memberAccessMutex_); + auto result = nlohmann::json::object(); + for (auto const& [name, serializer] : publicConfigSectionSerializers_) { + nlohmann::json section = nlohmann::json::object(); + if (!serializer) { + result[name] = std::move(section); + continue; + } + + try { + auto serialized = serializer(fullConfig); + if (serialized.is_object()) { + section = std::move(serialized); + } else { + log().warn( + "Public config section {} serializer returned non-object payload. Replacing with empty object.", + name); + } + } + catch (std::exception const& e) { + log().warn( + "Public config section {} serializer failed: {}. Replacing with empty object.", + name, + e.what()); + } + catch (...) { + log().warn( + "Public config section {} serializer failed with unknown error. Replacing with empty object.", + name); + } + + result[name] = std::move(section); + } + return result; +} + void DataSourceConfigService::validateDataSourceConfig(nlohmann::json json) const { std::lock_guard memberAccessLock(memberAccessMutex_); @@ -573,6 +716,8 @@ void DataSourceConfigService::reset() { configFilePath_.clear(); schema_.reset(); validator_.reset(); + publicConfigSectionSerializers_.clear(); + dataSourceConfigStats_ = {}; end(); } diff --git a/libs/service/src/service.cpp b/libs/service/src/service.cpp index d4101499..2ab2ea11 100644 --- a/libs/service/src/service.cpp +++ b/libs/service/src/service.cpp @@ -45,6 +45,18 @@ std::vector> normalizeTileBuckets(std::vector(true); + } catch (...) { + return true; + } +} + } // namespace LayerTilesRequest::LayerTilesRequest( @@ -603,6 +615,7 @@ struct Service::Impl : public Service::Controller mutable std::shared_mutex dataSourcesMutex_; std::unique_ptr configSubscription_; std::vector dataSourcesFromConfig_; + size_t dataSourceConstructionFailed_ = 0; explicit Impl( Cache::Ptr cache, @@ -630,12 +643,18 @@ struct Service::Impl : public Service::Controller // Add datasources present in the new configuration. auto index = 0; std::vector configuredDataSources; + size_t constructionFailures = 0; for (const auto& configNode : dataSourceConfigNodes) { + if (!isDataSourceDescriptorEnabled(configNode)) { + ++index; + continue; + } if (auto dataSource = DataSourceConfigService::get().makeDataSource(configNode)) { addDataSource(dataSource); configuredDataSources.push_back(dataSource); } else { + ++constructionFailures; log().error( "Failed to make datasource at index {}.", index); } @@ -644,6 +663,7 @@ struct Service::Impl : public Service::Controller std::unique_lock lock(dataSourcesMutex_); dataSourcesFromConfig_ = std::move(configuredDataSources); + dataSourceConstructionFailed_ = constructionFailures; }); } @@ -665,6 +685,7 @@ struct Service::Impl : public Service::Controller dataSourceInfo_.clear(); addOnDataSources_.clear(); dataSourcesFromConfig_.clear(); + dataSourceConstructionFailed_ = 0; } // Wake up all workers to check shouldTerminate_. jobsAvailable_.notify_all(); @@ -1038,9 +1059,28 @@ LayerRequestContext Service::resolveLayerRequest( if (layerExists && unauthorized) { result.status_ = RequestStatus::Unauthorized; + result.noDataSourceReason_ = NoDataSourceReason::None; } else { result.status_ = RequestStatus::NoDataSource; + result.noDataSourceReason_ = NoDataSourceReason::MissingMapOrLayer; + + if (impl_->dataSourceInfo_.empty()) { + auto const configPath = DataSourceConfigService::get().getConfigFilePath(); + auto const configStats = DataSourceConfigService::get().getDataSourceConfigStats(); + if (!configPath.has_value()) { + result.noDataSourceReason_ = NoDataSourceReason::NoConfig; + } + else if (configStats.configured == 0) { + result.noDataSourceReason_ = NoDataSourceReason::EmptySources; + } + else if (configStats.enabled == 0) { + result.noDataSourceReason_ = NoDataSourceReason::AllSourcesDisabled; + } + else if (impl_->dataSourceConstructionFailed_ > 0) { + result.noDataSourceReason_ = NoDataSourceReason::DatasourceInitializationFailed; + } + } } return result; } @@ -1145,13 +1185,25 @@ nlohmann::json Service::getStatistics(bool includeCachedFeatureTreeBytes, bool i } size_t activeRequests = 0; + size_t constructionFailures = 0; { std::unique_lock lock(impl_->jobsMutex_); activeRequests = impl_->requests_.size(); } + { + std::shared_lock lock(impl_->dataSourcesMutex_); + constructionFailures = impl_->dataSourceConstructionFailed_; + } + auto configStats = DataSourceConfigService::get().getDataSourceConfigStats(); auto result = nlohmann::json{ {"datasources", datasources}, - {"active-requests", activeRequests} + {"active-requests", activeRequests}, + {"datasource-config", nlohmann::json{ + {"configured", configStats.configured}, + {"enabled", configStats.enabled}, + {"disabled", configStats.disabled}, + {"construction-failed", constructionFailures} + }} }; if (!includeCachedFeatureTreeBytes && !includeTileSizeDistribution) { diff --git a/test/integration/CMakeLists.txt b/test/integration/CMakeLists.txt index 1af0caf9..15182888 100644 --- a/test/integration/CMakeLists.txt +++ b/test/integration/CMakeLists.txt @@ -11,6 +11,12 @@ add_wheel_test(test-local-example # that does not require networking -f "./cpp-sample-local-service") +add_wheel_test(test-python-bindings-smoke + WORKING_DIRECTORY + "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}" + COMMANDS + -f "python ${CMAKE_CURRENT_LIST_DIR}/python-bindings-smoke.py") + if (NOT WITH_COVERAGE) # Python wheels currently don't play well with gcov. message(STATUS "WITH_COVERAGE: ${WITH_COVERAGE}: Enabling wheel-based integration test.") diff --git a/test/integration/detect-ports-and-prepare-config-yaml.py b/test/integration/detect-ports-and-prepare-config-yaml.py index df8e652a..03654fc8 100644 --- a/test/integration/detect-ports-and-prepare-config-yaml.py +++ b/test/integration/detect-ports-and-prepare-config-yaml.py @@ -129,7 +129,10 @@ def main() -> int: examples_config = repo_root / "examples" / "config" sample_service = (examples_config / "sample-service.yaml").read_text(encoding="utf-8") - cache_path = str((out_dir / "mapget-cache.db").resolve()) + cache_file = out_dir / "mapget-cache.db" + for stale_cache_file in (cache_file, out_dir / "mapget-cache.db-shm", out_dir / "mapget-cache.db-wal"): + stale_cache_file.unlink(missing_ok=True) + cache_path = str(cache_file.resolve()) (out_dir / "sample-service.yaml").write_text( _patch_cache_dir( _patch_sample_service_yaml(sample_service, mapget_port, datasource_cpp_port, datasource_py_port), diff --git a/test/integration/mapget-exec-datasource.bash b/test/integration/mapget-exec-datasource.bash index d2b31083..b82f78da 100755 --- a/test/integration/mapget-exec-datasource.bash +++ b/test/integration/mapget-exec-datasource.bash @@ -5,8 +5,9 @@ set -euo src_dir="$(cd "$(dirname "$0")" && pwd)/../.." example_dir="$src_dir/examples" -if [[ "$OSTYPE" == "msys" ]]; then - # On Windows, convert path from /c/Users to C:/Users so python understands it. +if command -v cygpath >/dev/null 2>&1; then + # On Windows-hosted bash variants, normalize /c/... or /d/... paths into a + # form the native Python executable understands. example_dir=$(cygpath -m "$example_dir") fi diff --git a/test/integration/python-bindings-smoke.py b/test/integration/python-bindings-smoke.py new file mode 100644 index 00000000..f5871f48 --- /dev/null +++ b/test/integration/python-bindings-smoke.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import urllib.request + +import mapget + + +def _get_json(url: str): + with urllib.request.urlopen(url, timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + + +def _post_json(url: str, body: dict): + request = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={"content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=10) as response: + payload = response.read().decode("utf-8") + return json.loads(payload) if payload else None + + +def main() -> int: + point = mapget.Point + cache_expired_calls: list[tuple[str, int]] = [] + requested_stages: list[int | None] = [] + + def fill_feature_tile(tile: mapget.TileFeatureLayer) -> None: + requested_stages.append(tile.stage()) + feature = tile.new_feature("Way", [("wayId", 1)]) + feature.set_lod(3) + assert feature.lod() == 3 + + geometry = feature.geom().new_geometry(mapget.GeomType.LINE) + geometry.append(point(1.0, 2.0)) + geometry.append(point(2.0, 3.0)) + assert geometry.stage() == tile.stage() + geometry.set_stage(tile.stage()) + + attr = feature.attribute_layers().new_layer("rules").new_attribute("speed") + attr.validity().new_offset_range( + mapget.ValidityGeometryOffsetType.RELATIVE_LENGTH, + 0.1, + 0.5, + direction=mapget.Direction.POSITIVE, + ) + attr.add_field("value", 42) + + target = tile.new_feature_id("Way", [("wayId", 2)]) + relation = feature.add_relation("next", target) + relation.source_validity().new_complete() + + source_refs = tile.new_source_data_references( + [("RawLayer", "primary", mapget.SourceDataAddress(10, 20))] + ) + feature.set_source_data_references(source_refs) + + def fill_source_data_tile(tile: mapget.TileSourceDataLayer) -> None: + compound = tile.new_compound(2) + compound.set_schema_name("example.Type") + compound.set_source_data_address(mapget.SourceDataAddress(1, 8)) + compound.add_field("answer", 42) + tile.add_root(compound) + + def locate(request: mapget.LocateRequest) -> list[mapget.LocateResponse]: + response = mapget.LocateResponse(request) + response.tile_key = mapget.MapTileKey( + mapget.LayerType.FEATURES, + request.map_id, + "WayLayer", + mapget.TileId(1), + 0, + ) + return [response] + + def on_cache_expired(tile_key: mapget.MapTileKey, expired_at_us: int) -> None: + cache_expired_calls.append((tile_key.to_string(), expired_at_us)) + + datasource = mapget.DataSourceServer( + { + "nodeId": "python-bindings-smoke", + "mapId": "Map", + "layers": { + "WayLayer": { + "stages": 3, + "stageLabels": ["Preview", "Complete", "Validation"], + "highFidelityStage": 1, + "featureTypes": [ + { + "name": "Way", + "uniqueIdCompositions": [[{"partId": "wayId", "datatype": "I64"}]], + } + ] + }, + "RawLayer": {"type": "SourceData"}, + }, + } + ) + datasource.on_tile_feature_request(fill_feature_tile) + datasource.on_tile_sourcedata_request(fill_source_data_tile) + datasource.on_locate_request(locate) + datasource.on_cache_expired(on_cache_expired) + + datasource.go("127.0.0.1", 0, 1000) + try: + base_url = f"http://127.0.0.1:{datasource.port()}" + + feature_tile = _get_json(f"{base_url}/tile?layer=WayLayer&tileId=1&stage=2&responseType=json") + assert requested_stages == [2] + feature = feature_tile["features"][0] + assert feature["relations"][0]["name"] == "next" + assert feature["relations"][0]["sourceValidity"]["direction"] == "COMPLETE" + assert feature["properties"]["layer"]["rules"]["speed"]["validity"]["offsetType"] == "RelativeLengthOffset" + assert feature["_sourceData"][0]["qualifier"] == "primary" + + source_tile = _get_json(f"{base_url}/tile?layer=RawLayer&tileId=1&responseType=json") + assert source_tile == [{"answer": 42}] + + locate_response = _post_json( + f"{base_url}/locate", + {"mapId": "Map", "typeId": "Way", "featureId": ["wayId", 1]}, + ) + assert locate_response[0]["tileId"] == "Features:Map:WayLayer:1:0" + + _post_json( + f"{base_url}/cache-expired", + {"tileKey": "Features:Map:WayLayer:1:0", "expiredAt": 123456}, + ) + assert cache_expired_calls == [("Features:Map:WayLayer:1:0", 123456)] + finally: + datasource.stop() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index af35f5cb..d20506b7 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable(test.mapget test-cache.cpp test-service-ttl.cpp test-config.cpp + test-geojson-import.cpp test-geojsonsource.cpp utility.cpp utility.h) diff --git a/test/unit/data/large-geojson-featureset.feature-collection.json b/test/unit/data/large-geojson-featureset.feature-collection.json new file mode 100644 index 00000000..7e22ef2e --- /dev/null +++ b/test/unit/data/large-geojson-featureset.feature-collection.json @@ -0,0 +1,7263 @@ +{ + "features": [ + { + "_sourceData": [ + { + "address": 37795712204830, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 4294967296024, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.0, + 48.0, + 0.0 + ], + [ + 11.000450134277344, + 48.000221252441406, + 0.0 + ], + [ + 11.000900268554688, + 48.00043869018555, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.0", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-000", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-0", + "b-0" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.0", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.1", + "toConnectedEnd": "START", + "transitionNumber": 1 + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": null, + "rank": 0, + "summary": { + "kind": "kind-0", + "score": 0.0 + } + }, + "recordId": 0, + "relations": [ + { + "_sourceData": [ + { + "address": 26628797235228, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.1", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.000200271606445, + 48.000099182128906, + 0.0 + ] + } + }, + { + "name": "shadow", + "sourceValidity": { + "direction": "COMPLETE" + }, + "target": "Entry.444000777123.group%2E00%25.2" + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.001500129699707, + 48.0, + 0.10000002384185791 + ], + [ + 11.001850128173828, + 48.000179290771484, + 0.10000002384185791 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.001899719238281, + 48.00019836425781, + 0.10000002384185791 + ], + [ + 11.002050399780273, + 48.00032043457031, + 0.2999999523162842 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.1", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-001", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-1", + "b-1" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.3", + "geometryName": "delta" + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-1", + "rank": 1, + "summary": { + "kind": "kind-1", + "score": 0.1 + } + }, + "recordId": 1, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.003000259399414, + 48.0, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.0, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.0004997253418, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.0004997253418, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.0, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.2", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-002", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-2", + "b-2" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-2", + "rank": 2, + "summary": { + "kind": "kind-2", + "score": 0.2 + } + }, + "recordId": 2, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.004500389099121, + 48.0, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.00014877319336, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.000518798828125, + 0.2999999523162842 + ], + [ + 11.004500389099121, + 48.0, + 0.2999999523162842 + ] + ] + ], + [ + [ + [ + 11.004950523376465, + 48.00014877319336, + 0.2999999523162842 + ], + [ + 11.005120277404785, + 48.00054931640625, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.000518798828125, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.00014877319336, + 0.2999999523162842 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.3", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-003", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-3", + "b-3" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004600524902344, + 48.000099182128906, + 0.30000001192092896 + ] + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-3", + "rank": 3, + "summary": { + "kind": "kind-3", + "score": 0.3 + } + }, + "recordId": 3, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.4", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004700660705566, + 48.000099182128906, + 0.30000001192092896 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.005999565124512, + 48.0, + 0.0 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.005999565124512, + 48.0, + 0.0 + ], + [ + 11.006699565099552, + 48.0, + 0.0 + ], + [ + 11.006699565099552, + 48.00054999999702, + 0.0 + ], + [ + 11.005999565124512, + 48.00054999999702, + 0.0 + ], + [ + 11.005999565124512, + 48.0, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.4", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-004", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-4", + "b-4" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.005999565124512, + 48.0, + 0.0 + ], + [ + 11.006239891052246, + 48.000179290771484, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-4", + "rank": 4, + "summary": { + "kind": "kind-0", + "score": 0.4 + } + }, + "recordId": 4, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 38074885079064, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.007499694824219, + 48.0, + 0.10000002384185791 + ], + [ + 11.007780075073242, + 48.0001106262207, + 0.3999999761581421 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.5", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-005", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-5", + "b-5" + ] + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-5", + "rank": 5, + "summary": { + "kind": "kind-1", + "score": 0.5 + } + }, + "recordId": 5, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 4629974745118, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.008999824523926, + 48.0, + 0.20000004768371582 + ], + [ + 11.00944995880127, + 48.000221252441406, + 0.20000004768371582 + ], + [ + 11.009900093078613, + 48.00043869018555, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.6", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-006", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-6", + "b-6" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.6", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.7", + "toConnectedEnd": "START", + "transitionNumber": 2 + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": "note-6", + "rank": 6, + "summary": { + "kind": "kind-2", + "score": 0.6 + } + }, + "recordId": 6, + "relations": [ + { + "_sourceData": [ + { + "address": 26963804684322, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.7", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.009200096130371, + 48.000099182128906, + 0.20000000298023224 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.010499954223633, + 48.0, + 0.2999999523162842 + ], + [ + 11.010849952697754, + 48.000179290771484, + 0.2999999523162842 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.010899543762207, + 48.00019836425781, + 0.2999999523162842 + ], + [ + 11.0110502243042, + 48.00032043457031, + 0.5 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.7", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-007", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-7", + "b-7" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.9", + "geometryName": "delta" + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-0", + "rank": 7, + "summary": { + "kind": "kind-3", + "score": 0.7 + } + }, + "recordId": 7, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.01200008392334, + 48.0, + 0.0 + ], + [ + 11.012650489807129, + 48.0, + 0.0 + ], + [ + 11.012650489807129, + 48.0004997253418, + 0.0 + ], + [ + 11.01200008392334, + 48.0004997253418, + 0.0 + ], + [ + 11.01200008392334, + 48.0, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.8", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-008", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-8", + "b-8" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-1", + "rank": 8, + "summary": { + "kind": "kind-0", + "score": 0.8 + } + }, + "recordId": 8, + "relations": [ + { + "name": "shadow", + "sourceValidity": { + "direction": "COMPLETE" + }, + "target": "Entry.444000777123.group%2E00%25.10" + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.013500213623047, + 48.0, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.00014877319336, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.000518798828125, + 0.10000002384185791 + ], + [ + 11.013500213623047, + 48.0, + 0.10000002384185791 + ] + ] + ], + [ + [ + [ + 11.01395034790039, + 48.00014877319336, + 0.10000002384185791 + ], + [ + 11.014120101928711, + 48.00054931640625, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.000518798828125, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.00014877319336, + 0.10000002384185791 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.9", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-009", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-9", + "b-9" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.01360034942627, + 48.000099182128906, + 0.10000000149011612 + ] + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-2", + "rank": 9, + "summary": { + "kind": "kind-1", + "score": 0.0 + } + }, + "recordId": 9, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.10", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.013700485229492, + 48.000099182128906, + 0.10000000149011612 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 38354057953309, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.015000343322754, + 48.0, + 0.20000004768371582 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.015000343322754, + 48.0, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.0, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.00054999999702, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.00054999999702, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.0, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.10", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-010", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-10", + "b-10" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.015000343322754, + 48.0, + 0.20000004768371582 + ], + [ + 11.015240669250488, + 48.000179290771484, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": null, + "rank": 10, + "summary": { + "kind": "kind-2", + "score": 0.1 + } + }, + "recordId": 10, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.016500473022461, + 48.0, + 0.2999999523162842 + ], + [ + 11.016780853271484, + 48.0001106262207, + 0.6000000238418579 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.11", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-011", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-11", + "b-11" + ] + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-4", + "rank": 11, + "summary": { + "kind": "kind-3", + "score": 0.2 + } + }, + "recordId": 11, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 4964982194201, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.0, + 48.00120162963867, + 0.0 + ], + [ + 11.000450134277344, + 48.00142288208008, + 0.0 + ], + [ + 11.000900268554688, + 48.00164031982422, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.12", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-012", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-12", + "b-12" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.12", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.13", + "toConnectedEnd": "START", + "transitionNumber": 3 + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": "note-5", + "rank": 12, + "summary": { + "kind": "kind-0", + "score": 0.3 + } + }, + "recordId": 12, + "relations": [ + { + "_sourceData": [ + { + "address": 27298812133405, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.13", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.000200271606445, + 48.00130081176758, + 0.0 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.001500129699707, + 48.00120162963867, + 0.10000002384185791 + ], + [ + 11.001850128173828, + 48.001380920410156, + 0.10000002384185791 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.001899719238281, + 48.001399993896484, + 0.10000002384185791 + ], + [ + 11.002050399780273, + 48.001522064208984, + 0.2999999523162842 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.13", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-013", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-13", + "b-13" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.15", + "geometryName": "delta" + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-6", + "rank": 13, + "summary": { + "kind": "kind-1", + "score": 0.4 + } + }, + "recordId": 13, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.003000259399414, + 48.00120162963867, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.00120162963867, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.00170135498047, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.00170135498047, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.00120162963867, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.14", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-014", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-14", + "b-14" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-0", + "rank": 14, + "summary": { + "kind": "kind-2", + "score": 0.5 + } + }, + "recordId": 14, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 38633230827554, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.004500389099121, + 48.00120162963867, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.00135040283203, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.0017204284668, + 0.2999999523162842 + ], + [ + 11.004500389099121, + 48.00120162963867, + 0.2999999523162842 + ] + ] + ], + [ + [ + [ + 11.004950523376465, + 48.00135040283203, + 0.2999999523162842 + ], + [ + 11.005120277404785, + 48.00175094604492, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.0017204284668, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.00135040283203, + 0.2999999523162842 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.15", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-015", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-15", + "b-15" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004600524902344, + 48.00130081176758, + 0.30000001192092896 + ] + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-1", + "rank": 15, + "summary": { + "kind": "kind-3", + "score": 0.6 + } + }, + "recordId": 15, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.16", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004700660705566, + 48.00130081176758, + 0.30000001192092896 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.005999565124512, + 48.00120162963867, + 0.0 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.005999565124512, + 48.00120162963867, + 0.0 + ], + [ + 11.006699565099552, + 48.00120162963867, + 0.0 + ], + [ + 11.006699565099552, + 48.00175162963569, + 0.0 + ], + [ + 11.005999565124512, + 48.00175162963569, + 0.0 + ], + [ + 11.005999565124512, + 48.00120162963867, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.16", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-016", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-16", + "b-16" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.005999565124512, + 48.00120162963867, + 0.0 + ], + [ + 11.006239891052246, + 48.001380920410156, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-2", + "rank": 16, + "summary": { + "kind": "kind-0", + "score": 0.7 + } + }, + "recordId": 16, + "relations": [ + { + "name": "shadow", + "sourceValidity": { + "direction": "COMPLETE" + }, + "target": "Entry.444000777123.group%2E00%25.18" + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.007499694824219, + 48.00120162963867, + 0.10000002384185791 + ], + [ + 11.007780075073242, + 48.001312255859375, + 0.3999999761581421 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.17", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-017", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-17", + "b-17" + ] + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-3", + "rank": 0, + "summary": { + "kind": "kind-1", + "score": 0.8 + } + }, + "recordId": 17, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 5299989643295, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.008999824523926, + 48.00120162963867, + 0.20000004768371582 + ], + [ + 11.00944995880127, + 48.00142288208008, + 0.20000004768371582 + ], + [ + 11.009900093078613, + 48.00164031982422, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.18", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-018", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-18", + "b-18" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.18", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.19", + "toConnectedEnd": "START", + "transitionNumber": 4 + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": "note-4", + "rank": 1, + "summary": { + "kind": "kind-2", + "score": 0.0 + } + }, + "recordId": 18, + "relations": [ + { + "_sourceData": [ + { + "address": 27633819582488, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.19", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.009200096130371, + 48.00130081176758, + 0.20000000298023224 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.010499954223633, + 48.00120162963867, + 0.2999999523162842 + ], + [ + 11.010849952697754, + 48.001380920410156, + 0.2999999523162842 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.010899543762207, + 48.001399993896484, + 0.2999999523162842 + ], + [ + 11.0110502243042, + 48.001522064208984, + 0.5 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.19", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-019", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-19", + "b-19" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.21", + "geometryName": "delta" + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-5", + "rank": 2, + "summary": { + "kind": "kind-3", + "score": 0.1 + } + }, + "recordId": 19, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 38912403701788, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.01200008392334, + 48.00120162963867, + 0.0 + ], + [ + 11.012650489807129, + 48.00120162963867, + 0.0 + ], + [ + 11.012650489807129, + 48.00170135498047, + 0.0 + ], + [ + 11.01200008392334, + 48.00170135498047, + 0.0 + ], + [ + 11.01200008392334, + 48.00120162963867, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.20", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-020", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-20", + "b-20" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": null, + "rank": 3, + "summary": { + "kind": "kind-0", + "score": 0.2 + } + }, + "recordId": 20, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.013500213623047, + 48.00120162963867, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.00135040283203, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.0017204284668, + 0.10000002384185791 + ], + [ + 11.013500213623047, + 48.00120162963867, + 0.10000002384185791 + ] + ] + ], + [ + [ + [ + 11.01395034790039, + 48.00135040283203, + 0.10000002384185791 + ], + [ + 11.014120101928711, + 48.00175094604492, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.0017204284668, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.00135040283203, + 0.10000002384185791 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.21", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-021", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-21", + "b-21" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.01360034942627, + 48.00130081176758, + 0.10000000149011612 + ] + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-0", + "rank": 4, + "summary": { + "kind": "kind-1", + "score": 0.3 + } + }, + "recordId": 21, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.22", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.013700485229492, + 48.00130081176758, + 0.10000000149011612 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.015000343322754, + 48.00120162963867, + 0.20000004768371582 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.015000343322754, + 48.00120162963867, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.00120162963867, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.00175162963569, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.00175162963569, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.00120162963867, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.22", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-022", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-22", + "b-22" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.015000343322754, + 48.00120162963867, + 0.20000004768371582 + ], + [ + 11.015240669250488, + 48.001380920410156, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-1", + "rank": 5, + "summary": { + "kind": "kind-2", + "score": 0.4 + } + }, + "recordId": 22, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.016500473022461, + 48.00120162963867, + 0.2999999523162842 + ], + [ + 11.016780853271484, + 48.001312255859375, + 0.6000000238418579 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.23", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-023", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-23", + "b-23" + ] + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-2", + "rank": 6, + "summary": { + "kind": "kind-3", + "score": 0.5 + } + }, + "recordId": 23, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 5634997092378, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.0, + 48.00239944458008, + 0.0 + ], + [ + 11.000450134277344, + 48.002620697021484, + 0.0 + ], + [ + 11.000900268554688, + 48.002838134765625, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.24", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-024", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-24", + "b-24" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.24", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.25", + "toConnectedEnd": "START", + "transitionNumber": 5 + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": "note-3", + "rank": 7, + "summary": { + "kind": "kind-0", + "score": 0.6 + } + }, + "recordId": 24, + "relations": [ + { + "_sourceData": [ + { + "address": 27968827031582, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.25", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.000200271606445, + 48.002498626708984, + 0.0 + ] + } + }, + { + "name": "shadow", + "sourceValidity": { + "direction": "COMPLETE" + }, + "target": "Entry.444000777123.group%2E00%25.26" + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 39191576576033, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.001500129699707, + 48.00239944458008, + 0.10000002384185791 + ], + [ + 11.001850128173828, + 48.00257873535156, + 0.10000002384185791 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.001899719238281, + 48.00259780883789, + 0.10000002384185791 + ], + [ + 11.002050399780273, + 48.00271987915039, + 0.2999999523162842 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.25", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-025", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-25", + "b-25" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.27", + "geometryName": "delta" + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-4", + "rank": 8, + "summary": { + "kind": "kind-1", + "score": 0.7 + } + }, + "recordId": 25, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.003000259399414, + 48.00239944458008, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.00239944458008, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.002899169921875, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.002899169921875, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.00239944458008, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.26", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-026", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-26", + "b-26" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-5", + "rank": 9, + "summary": { + "kind": "kind-2", + "score": 0.8 + } + }, + "recordId": 26, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.004500389099121, + 48.00239944458008, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.00254821777344, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.0029182434082, + 0.2999999523162842 + ], + [ + 11.004500389099121, + 48.00239944458008, + 0.2999999523162842 + ] + ] + ], + [ + [ + [ + 11.004950523376465, + 48.00254821777344, + 0.2999999523162842 + ], + [ + 11.005120277404785, + 48.00294876098633, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.0029182434082, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.00254821777344, + 0.2999999523162842 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.27", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-027", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-27", + "b-27" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004600524902344, + 48.002498626708984, + 0.30000001192092896 + ] + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-6", + "rank": 10, + "summary": { + "kind": "kind-3", + "score": 0.0 + } + }, + "recordId": 27, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.28", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004700660705566, + 48.002498626708984, + 0.30000001192092896 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.005999565124512, + 48.00239944458008, + 0.0 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.005999565124512, + 48.00239944458008, + 0.0 + ], + [ + 11.006699565099552, + 48.00239944458008, + 0.0 + ], + [ + 11.006699565099552, + 48.0029494445771, + 0.0 + ], + [ + 11.005999565124512, + 48.0029494445771, + 0.0 + ], + [ + 11.005999565124512, + 48.00239944458008, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.28", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-028", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-28", + "b-28" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.005999565124512, + 48.00239944458008, + 0.0 + ], + [ + 11.006239891052246, + 48.00257873535156, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-0", + "rank": 11, + "summary": { + "kind": "kind-0", + "score": 0.1 + } + }, + "recordId": 28, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.007499694824219, + 48.00239944458008, + 0.10000002384185791 + ], + [ + 11.007780075073242, + 48.00251007080078, + 0.3999999761581421 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.29", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-029", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-29", + "b-29" + ] + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-1", + "rank": 12, + "summary": { + "kind": "kind-1", + "score": 0.2 + } + }, + "recordId": 29, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 39470749450267, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 5970004541472, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.008999824523926, + 48.00239944458008, + 0.20000004768371582 + ], + [ + 11.00944995880127, + 48.002620697021484, + 0.20000004768371582 + ], + [ + 11.009900093078613, + 48.002838134765625, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.30", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-030", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-30", + "b-30" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.30", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.31", + "toConnectedEnd": "START", + "transitionNumber": 6 + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": null, + "rank": 13, + "summary": { + "kind": "kind-2", + "score": 0.3 + } + }, + "recordId": 30, + "relations": [ + { + "_sourceData": [ + { + "address": 28303834480665, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.31", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.009200096130371, + 48.002498626708984, + 0.20000000298023224 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.010499954223633, + 48.00239944458008, + 0.2999999523162842 + ], + [ + 11.010849952697754, + 48.00257873535156, + 0.2999999523162842 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.010899543762207, + 48.00259780883789, + 0.2999999523162842 + ], + [ + 11.0110502243042, + 48.00271987915039, + 0.5 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.31", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-031", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-31", + "b-31" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.33", + "geometryName": "delta" + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-3", + "rank": 14, + "summary": { + "kind": "kind-3", + "score": 0.4 + } + }, + "recordId": 31, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.01200008392334, + 48.00239944458008, + 0.0 + ], + [ + 11.012650489807129, + 48.00239944458008, + 0.0 + ], + [ + 11.012650489807129, + 48.002899169921875, + 0.0 + ], + [ + 11.01200008392334, + 48.002899169921875, + 0.0 + ], + [ + 11.01200008392334, + 48.00239944458008, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.32", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-032", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-32", + "b-32" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-4", + "rank": 15, + "summary": { + "kind": "kind-0", + "score": 0.5 + } + }, + "recordId": 32, + "relations": [ + { + "name": "shadow", + "sourceValidity": { + "direction": "COMPLETE" + }, + "target": "Entry.444000777123.group%2E00%25.34" + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.013500213623047, + 48.00239944458008, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.00254821777344, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.0029182434082, + 0.10000002384185791 + ], + [ + 11.013500213623047, + 48.00239944458008, + 0.10000002384185791 + ] + ] + ], + [ + [ + [ + 11.01395034790039, + 48.00254821777344, + 0.10000002384185791 + ], + [ + 11.014120101928711, + 48.00294876098633, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.0029182434082, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.00254821777344, + 0.10000002384185791 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.33", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-033", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-33", + "b-33" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.01360034942627, + 48.002498626708984, + 0.10000000149011612 + ] + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-5", + "rank": 16, + "summary": { + "kind": "kind-1", + "score": 0.6 + } + }, + "recordId": 33, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.34", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.013700485229492, + 48.002498626708984, + 0.10000000149011612 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.015000343322754, + 48.00239944458008, + 0.20000004768371582 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.015000343322754, + 48.00239944458008, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.00239944458008, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.0029494445771, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.0029494445771, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.00239944458008, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.34", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-034", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-34", + "b-34" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.015000343322754, + 48.00239944458008, + 0.20000004768371582 + ], + [ + 11.015240669250488, + 48.00257873535156, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-6", + "rank": 0, + "summary": { + "kind": "kind-2", + "score": 0.7 + } + }, + "recordId": 34, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 39749922324512, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.016500473022461, + 48.00239944458008, + 0.2999999523162842 + ], + [ + 11.016780853271484, + 48.00251007080078, + 0.6000000238418579 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.35", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-035", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-35", + "b-35" + ] + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-0", + "rank": 1, + "summary": { + "kind": "kind-3", + "score": 0.8 + } + }, + "recordId": 35, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 6305011990555, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.0, + 48.00360107421875, + 0.0 + ], + [ + 11.000450134277344, + 48.003822326660156, + 0.0 + ], + [ + 11.000900268554688, + 48.0040397644043, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.36", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-036", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-36", + "b-36" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.36", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.37", + "toConnectedEnd": "START", + "transitionNumber": 7 + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": "note-1", + "rank": 2, + "summary": { + "kind": "kind-0", + "score": 0.0 + } + }, + "recordId": 36, + "relations": [ + { + "_sourceData": [ + { + "address": 28638841929759, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.37", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.000200271606445, + 48.003700256347656, + 0.0 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.001500129699707, + 48.00360107421875, + 0.10000002384185791 + ], + [ + 11.001850128173828, + 48.003780364990234, + 0.10000002384185791 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.001899719238281, + 48.00379943847656, + 0.10000002384185791 + ], + [ + 11.002050399780273, + 48.00392150878906, + 0.2999999523162842 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.37", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-037", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-37", + "b-37" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.39", + "geometryName": "delta" + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-2", + "rank": 3, + "summary": { + "kind": "kind-1", + "score": 0.1 + } + }, + "recordId": 37, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.003000259399414, + 48.00360107421875, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.00360107421875, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.00410079956055, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.00410079956055, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.00360107421875, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.38", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-038", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-38", + "b-38" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-3", + "rank": 4, + "summary": { + "kind": "kind-2", + "score": 0.2 + } + }, + "recordId": 38, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.004500389099121, + 48.00360107421875, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.00374984741211, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.004119873046875, + 0.2999999523162842 + ], + [ + 11.004500389099121, + 48.00360107421875, + 0.2999999523162842 + ] + ] + ], + [ + [ + [ + 11.004950523376465, + 48.00374984741211, + 0.2999999523162842 + ], + [ + 11.005120277404785, + 48.004150390625, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.004119873046875, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.00374984741211, + 0.2999999523162842 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.39", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-039", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-39", + "b-39" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004600524902344, + 48.003700256347656, + 0.30000001192092896 + ] + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-4", + "rank": 5, + "summary": { + "kind": "kind-3", + "score": 0.3 + } + }, + "recordId": 39, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.40", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004700660705566, + 48.003700256347656, + 0.30000001192092896 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 40029095198746, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.005999565124512, + 48.00360107421875, + 0.0 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.005999565124512, + 48.00360107421875, + 0.0 + ], + [ + 11.006699565099552, + 48.00360107421875, + 0.0 + ], + [ + 11.006699565099552, + 48.00415107421577, + 0.0 + ], + [ + 11.005999565124512, + 48.00415107421577, + 0.0 + ], + [ + 11.005999565124512, + 48.00360107421875, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.40", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-040", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-40", + "b-40" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.005999565124512, + 48.00360107421875, + 0.0 + ], + [ + 11.006239891052246, + 48.003780364990234, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": null, + "rank": 6, + "summary": { + "kind": "kind-0", + "score": 0.4 + } + }, + "recordId": 40, + "relations": [ + { + "name": "shadow", + "sourceValidity": { + "direction": "COMPLETE" + }, + "target": "Entry.444000777123.group%2E00%25.42" + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.007499694824219, + 48.00360107421875, + 0.10000002384185791 + ], + [ + 11.007780075073242, + 48.00371170043945, + 0.3999999761581421 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.41", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-041", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-41", + "b-41" + ] + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-6", + "rank": 7, + "summary": { + "kind": "kind-1", + "score": 0.5 + } + }, + "recordId": 41, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 6640019439649, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.008999824523926, + 48.00360107421875, + 0.20000004768371582 + ], + [ + 11.00944995880127, + 48.003822326660156, + 0.20000004768371582 + ], + [ + 11.009900093078613, + 48.0040397644043, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.42", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-042", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-42", + "b-42" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.42", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.43", + "toConnectedEnd": "START", + "transitionNumber": 8 + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": "note-0", + "rank": 8, + "summary": { + "kind": "kind-2", + "score": 0.6 + } + }, + "recordId": 42, + "relations": [ + { + "_sourceData": [ + { + "address": 28973849378842, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.43", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.009200096130371, + 48.003700256347656, + 0.20000000298023224 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.010499954223633, + 48.00360107421875, + 0.2999999523162842 + ], + [ + 11.010849952697754, + 48.003780364990234, + 0.2999999523162842 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.010899543762207, + 48.00379943847656, + 0.2999999523162842 + ], + [ + 11.0110502243042, + 48.00392150878906, + 0.5 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.43", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-043", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-43", + "b-43" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.45", + "geometryName": "delta" + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-1", + "rank": 9, + "summary": { + "kind": "kind-3", + "score": 0.7 + } + }, + "recordId": 43, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.01200008392334, + 48.00360107421875, + 0.0 + ], + [ + 11.012650489807129, + 48.00360107421875, + 0.0 + ], + [ + 11.012650489807129, + 48.00410079956055, + 0.0 + ], + [ + 11.01200008392334, + 48.00410079956055, + 0.0 + ], + [ + 11.01200008392334, + 48.00360107421875, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.44", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-044", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-44", + "b-44" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-2", + "rank": 10, + "summary": { + "kind": "kind-0", + "score": 0.8 + } + }, + "recordId": 44, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 40308268072991, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.013500213623047, + 48.00360107421875, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.00374984741211, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.004119873046875, + 0.10000002384185791 + ], + [ + 11.013500213623047, + 48.00360107421875, + 0.10000002384185791 + ] + ] + ], + [ + [ + [ + 11.01395034790039, + 48.00374984741211, + 0.10000002384185791 + ], + [ + 11.014120101928711, + 48.004150390625, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.004119873046875, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.00374984741211, + 0.10000002384185791 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.45", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-045", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-45", + "b-45" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.01360034942627, + 48.003700256347656, + 0.10000000149011612 + ] + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-3", + "rank": 11, + "summary": { + "kind": "kind-1", + "score": 0.0 + } + }, + "recordId": 45, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.46", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.013700485229492, + 48.003700256347656, + 0.10000000149011612 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.015000343322754, + 48.00360107421875, + 0.20000004768371582 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.015000343322754, + 48.00360107421875, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.00360107421875, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.00415107421577, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.00415107421577, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.00360107421875, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.46", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-046", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-46", + "b-46" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.015000343322754, + 48.00360107421875, + 0.20000004768371582 + ], + [ + 11.015240669250488, + 48.003780364990234, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-4", + "rank": 12, + "summary": { + "kind": "kind-2", + "score": 0.1 + } + }, + "recordId": 46, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.016500473022461, + 48.00360107421875, + 0.2999999523162842 + ], + [ + 11.016780853271484, + 48.00371170043945, + 0.6000000238418579 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.47", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-047", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-47", + "b-47" + ] + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-5", + "rank": 13, + "summary": { + "kind": "kind-3", + "score": 0.2 + } + }, + "recordId": 47, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 6975026888732, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.0, + 48.004798889160156, + 0.0 + ], + [ + 11.000450134277344, + 48.00502014160156, + 0.0 + ], + [ + 11.000900268554688, + 48.0052375793457, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.48", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-048", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-48", + "b-48" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.48", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.49", + "toConnectedEnd": "START", + "transitionNumber": 9 + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": "note-6", + "rank": 14, + "summary": { + "kind": "kind-0", + "score": 0.3 + } + }, + "recordId": 48, + "relations": [ + { + "_sourceData": [ + { + "address": 29308856827936, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.49", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.000200271606445, + 48.00489807128906, + 0.0 + ] + } + }, + { + "name": "shadow", + "sourceValidity": { + "direction": "COMPLETE" + }, + "target": "Entry.444000777123.group%2E00%25.50" + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.001500129699707, + 48.004798889160156, + 0.10000002384185791 + ], + [ + 11.001850128173828, + 48.00497817993164, + 0.10000002384185791 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.001899719238281, + 48.00499725341797, + 0.10000002384185791 + ], + [ + 11.002050399780273, + 48.00511932373047, + 0.2999999523162842 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.49", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-049", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-49", + "b-49" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.51", + "geometryName": "delta" + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-0", + "rank": 15, + "summary": { + "kind": "kind-1", + "score": 0.4 + } + }, + "recordId": 49, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 40587440947225, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.003000259399414, + 48.004798889160156, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.004798889160156, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.00529861450195, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.00529861450195, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.004798889160156, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.50", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-050", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-50", + "b-50" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": null, + "rank": 16, + "summary": { + "kind": "kind-2", + "score": 0.5 + } + }, + "recordId": 50, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.004500389099121, + 48.004798889160156, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.004947662353516, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.00531768798828, + 0.2999999523162842 + ], + [ + 11.004500389099121, + 48.004798889160156, + 0.2999999523162842 + ] + ] + ], + [ + [ + [ + 11.004950523376465, + 48.004947662353516, + 0.2999999523162842 + ], + [ + 11.005120277404785, + 48.005348205566406, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.00531768798828, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.004947662353516, + 0.2999999523162842 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.51", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-051", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-51", + "b-51" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004600524902344, + 48.00489807128906, + 0.30000001192092896 + ] + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-2", + "rank": 0, + "summary": { + "kind": "kind-3", + "score": 0.6 + } + }, + "recordId": 51, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.52", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004700660705566, + 48.00489807128906, + 0.30000001192092896 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.005999565124512, + 48.004798889160156, + 0.0 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.005999565124512, + 48.004798889160156, + 0.0 + ], + [ + 11.006699565099552, + 48.004798889160156, + 0.0 + ], + [ + 11.006699565099552, + 48.005348889157176, + 0.0 + ], + [ + 11.005999565124512, + 48.005348889157176, + 0.0 + ], + [ + 11.005999565124512, + 48.004798889160156, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.52", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-052", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-52", + "b-52" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.005999565124512, + 48.004798889160156, + 0.0 + ], + [ + 11.006239891052246, + 48.00497817993164, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-3", + "rank": 1, + "summary": { + "kind": "kind-0", + "score": 0.7 + } + }, + "recordId": 52, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.007499694824219, + 48.004798889160156, + 0.10000002384185791 + ], + [ + 11.007780075073242, + 48.00490951538086, + 0.3999999761581421 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.53", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-053", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-53", + "b-53" + ] + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-4", + "rank": 2, + "summary": { + "kind": "kind-1", + "score": 0.8 + } + }, + "recordId": 53, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 7310034337826, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.008999824523926, + 48.004798889160156, + 0.20000004768371582 + ], + [ + 11.00944995880127, + 48.00502014160156, + 0.20000004768371582 + ], + [ + 11.009900093078613, + 48.0052375793457, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.54", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-054", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-54", + "b-54" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.54", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.55", + "toConnectedEnd": "START", + "transitionNumber": 10 + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": "note-5", + "rank": 3, + "summary": { + "kind": "kind-2", + "score": 0.0 + } + }, + "recordId": 54, + "relations": [ + { + "_sourceData": [ + { + "address": 29643864277019, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.55", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.009200096130371, + 48.00489807128906, + 0.20000000298023224 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 40866613821470, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.010499954223633, + 48.004798889160156, + 0.2999999523162842 + ], + [ + 11.010849952697754, + 48.00497817993164, + 0.2999999523162842 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.010899543762207, + 48.00499725341797, + 0.2999999523162842 + ], + [ + 11.0110502243042, + 48.00511932373047, + 0.5 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.55", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-055", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-55", + "b-55" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.57", + "geometryName": "delta" + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-6", + "rank": 4, + "summary": { + "kind": "kind-3", + "score": 0.1 + } + }, + "recordId": 55, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.01200008392334, + 48.004798889160156, + 0.0 + ], + [ + 11.012650489807129, + 48.004798889160156, + 0.0 + ], + [ + 11.012650489807129, + 48.00529861450195, + 0.0 + ], + [ + 11.01200008392334, + 48.00529861450195, + 0.0 + ], + [ + 11.01200008392334, + 48.004798889160156, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.56", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-056", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-56", + "b-56" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-0", + "rank": 5, + "summary": { + "kind": "kind-0", + "score": 0.2 + } + }, + "recordId": 56, + "relations": [ + { + "name": "shadow", + "sourceValidity": { + "direction": "COMPLETE" + }, + "target": "Entry.444000777123.group%2E00%25.58" + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.013500213623047, + 48.004798889160156, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.004947662353516, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.00531768798828, + 0.10000002384185791 + ], + [ + 11.013500213623047, + 48.004798889160156, + 0.10000002384185791 + ] + ] + ], + [ + [ + [ + 11.01395034790039, + 48.004947662353516, + 0.10000002384185791 + ], + [ + 11.014120101928711, + 48.005348205566406, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.00531768798828, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.004947662353516, + 0.10000002384185791 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.57", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-057", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-57", + "b-57" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.01360034942627, + 48.00489807128906, + 0.10000000149011612 + ] + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-1", + "rank": 6, + "summary": { + "kind": "kind-1", + "score": 0.3 + } + }, + "recordId": 57, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.58", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.013700485229492, + 48.00489807128906, + 0.10000000149011612 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.015000343322754, + 48.004798889160156, + 0.20000004768371582 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.015000343322754, + 48.004798889160156, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.004798889160156, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.005348889157176, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.005348889157176, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.004798889160156, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.58", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-058", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-58", + "b-58" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.015000343322754, + 48.004798889160156, + 0.20000004768371582 + ], + [ + 11.015240669250488, + 48.00497817993164, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-2", + "rank": 7, + "summary": { + "kind": "kind-2", + "score": 0.4 + } + }, + "recordId": 58, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.016500473022461, + 48.004798889160156, + 0.2999999523162842 + ], + [ + 11.016780853271484, + 48.00490951538086, + 0.6000000238418579 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.59", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-059", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-59", + "b-59" + ] + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-3", + "rank": 8, + "summary": { + "kind": "kind-3", + "score": 0.5 + } + }, + "recordId": 59, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 41145786695704, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 7645041786909, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.0, + 48.00600051879883, + 0.0 + ], + [ + 11.000450134277344, + 48.006221771240234, + 0.0 + ], + [ + 11.000900268554688, + 48.006439208984375, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.60", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-060", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-60", + "b-60" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.60", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.61", + "toConnectedEnd": "START", + "transitionNumber": 11 + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": null, + "rank": 9, + "summary": { + "kind": "kind-0", + "score": 0.6 + } + }, + "recordId": 60, + "relations": [ + { + "_sourceData": [ + { + "address": 29978871726113, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.61", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.000200271606445, + 48.006099700927734, + 0.0 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.001500129699707, + 48.00600051879883, + 0.10000002384185791 + ], + [ + 11.001850128173828, + 48.00617980957031, + 0.10000002384185791 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.001899719238281, + 48.00619888305664, + 0.10000002384185791 + ], + [ + 11.002050399780273, + 48.00632095336914, + 0.2999999523162842 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.61", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-061", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-61", + "b-61" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.63", + "geometryName": "delta" + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-5", + "rank": 10, + "summary": { + "kind": "kind-1", + "score": 0.7 + } + }, + "recordId": 61, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.003000259399414, + 48.00600051879883, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.00600051879883, + 0.20000004768371582 + ], + [ + 11.003650665283203, + 48.006500244140625, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.006500244140625, + 0.20000004768371582 + ], + [ + 11.003000259399414, + 48.00600051879883, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.62", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-062", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-62", + "b-62" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-6", + "rank": 11, + "summary": { + "kind": "kind-2", + "score": 0.8 + } + }, + "recordId": 62, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.004500389099121, + 48.00600051879883, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.00614929199219, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.00651931762695, + 0.2999999523162842 + ], + [ + 11.004500389099121, + 48.00600051879883, + 0.2999999523162842 + ] + ] + ], + [ + [ + [ + 11.004950523376465, + 48.00614929199219, + 0.2999999523162842 + ], + [ + 11.005120277404785, + 48.00654983520508, + 0.2999999523162842 + ], + [ + 11.004650115966797, + 48.00651931762695, + 0.2999999523162842 + ], + [ + 11.004950523376465, + 48.00614929199219, + 0.2999999523162842 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.63", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-063", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-63", + "b-63" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004600524902344, + 48.006099700927734, + 0.30000001192092896 + ] + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-0", + "rank": 12, + "summary": { + "kind": "kind-3", + "score": 0.0 + } + }, + "recordId": 63, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.64", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.004700660705566, + 48.006099700927734, + 0.30000001192092896 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.005999565124512, + 48.00600051879883, + 0.0 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.005999565124512, + 48.00600051879883, + 0.0 + ], + [ + 11.006699565099552, + 48.00600051879883, + 0.0 + ], + [ + 11.006699565099552, + 48.00655051879585, + 0.0 + ], + [ + 11.005999565124512, + 48.00655051879585, + 0.0 + ], + [ + 11.005999565124512, + 48.00600051879883, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.64", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-064", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-64", + "b-64" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.005999565124512, + 48.00600051879883, + 0.0 + ], + [ + 11.006239891052246, + 48.00617980957031, + 0.0 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-1", + "rank": 13, + "summary": { + "kind": "kind-0", + "score": 0.1 + } + }, + "recordId": 64, + "relations": [ + { + "name": "shadow", + "sourceValidity": { + "direction": "COMPLETE" + }, + "target": "Entry.444000777123.group%2E00%25.66" + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 41424959569949, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.007499694824219, + 48.00600051879883, + 0.10000002384185791 + ], + [ + 11.007780075073242, + 48.00611114501953, + 0.3999999761581421 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.65", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-065", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-65", + "b-65" + ] + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-2", + "rank": 14, + "summary": { + "kind": "kind-1", + "score": 0.2 + } + }, + "recordId": 65, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "_sourceData": [ + { + "address": 7980049235992, + "layerId": "src-set", + "qualifier": "geom-main" + } + ], + "coordinates": [ + [ + 11.008999824523926, + 48.00600051879883, + 0.20000004768371582 + ], + [ + 11.00944995880127, + 48.006221771240234, + 0.20000004768371582 + ], + [ + 11.009900093078613, + 48.006439208984375, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + }, + "id": "Entry.444000777123.group%2E00%25.66", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-066", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-66", + "b-66" + ] + }, + "validity": { + "direction": "POSITIVE", + "from": "Entry.444000777123.group%2E00%25.66", + "fromConnectedEnd": "END", + "to": "Entry.444000777123.group%2E00%25.67", + "toConnectedEnd": "START", + "transitionNumber": 12 + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "warm" + } + } + }, + "notes": "note-3", + "rank": 15, + "summary": { + "kind": "kind-2", + "score": 0.3 + } + }, + "recordId": 66, + "relations": [ + { + "_sourceData": [ + { + "address": 30313879175196, + "layerId": "src-set", + "qualifier": "rel-main" + } + ], + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.67", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.009200096130371, + 48.006099700927734, + 0.20000000298023224 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "geometries": [ + { + "coordinates": [ + [ + 11.010499954223633, + 48.00600051879883, + 0.2999999523162842 + ], + [ + 11.010849952697754, + 48.00617980957031, + 0.2999999523162842 + ] + ], + "type": "LineString" + }, + { + "coordinates": [ + [ + 11.010899543762207, + 48.00619888305664, + 0.2999999523162842 + ], + [ + 11.0110502243042, + 48.00632095336914, + 0.5 + ] + ], + "type": "MultiPoint" + } + ], + "type": "GeometryCollection" + }, + "id": "Entry.444000777123.group%2E00%25.67", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-067", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-67", + "b-67" + ] + }, + "validity": { + "direction": "NEGATIVE", + "featureId": "Entry.444000777123.group%2E00%25.69", + "geometryName": "delta" + }, + "value": "token-2" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-4", + "rank": 16, + "summary": { + "kind": "kind-3", + "score": 0.4 + } + }, + "recordId": 67, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + 11.01200008392334, + 48.00600051879883, + 0.0 + ], + [ + 11.012650489807129, + 48.00600051879883, + 0.0 + ], + [ + 11.012650489807129, + 48.006500244140625, + 0.0 + ], + [ + 11.01200008392334, + 48.006500244140625, + 0.0 + ], + [ + 11.01200008392334, + 48.00600051879883, + 0.0 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.68", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-068", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-68", + "b-68" + ] + }, + "validity": { + "direction": "POSITIVE", + "end": 0.8, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.1 + }, + "value": "token-3" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": "note-5", + "rank": 0, + "summary": { + "kind": "kind-0", + "score": 0.5 + } + }, + "recordId": 68, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + [ + [ + 11.013500213623047, + 48.00600051879883, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.00614929199219, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.00651931762695, + 0.10000002384185791 + ], + [ + 11.013500213623047, + 48.00600051879883, + 0.10000002384185791 + ] + ] + ], + [ + [ + [ + 11.01395034790039, + 48.00614929199219, + 0.10000002384185791 + ], + [ + 11.014120101928711, + 48.00654983520508, + 0.10000002384185791 + ], + [ + 11.013649940490723, + 48.00651931762695, + 0.10000002384185791 + ], + [ + 11.01395034790039, + 48.00614929199219, + 0.10000002384185791 + ] + ] + ] + ], + "geometryName": "delta", + "type": "MultiPolygon" + }, + "id": "Entry.444000777123.group%2E00%25.69", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-069", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-69", + "b-69" + ] + }, + "validity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.01360034942627, + 48.006099700927734, + 0.10000000149011612 + ] + }, + "value": "token-4" + } + }, + "status": { + "slotAux": { + "flag": true, + "state": "cold" + } + } + }, + "notes": "note-6", + "rank": 1, + "summary": { + "kind": "kind-1", + "score": 0.6 + } + }, + "recordId": 69, + "relations": [ + { + "name": "peer", + "sourceValidity": { + "direction": "POSITIVE", + "end": 0.7, + "geometryName": "delta", + "offsetType": "RelativeLengthOffset", + "start": 0.2 + }, + "target": "Entry.444000777123.group%2E00%25.70", + "targetValidity": { + "direction": "NEGATIVE", + "offsetType": "GeoPosOffset", + "point": [ + 11.013700485229492, + 48.006099700927734, + 0.10000000149011612 + ] + } + } + ], + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "_sourceData": [ + { + "address": 41704132444194, + "layerId": "src-set", + "qualifier": "feature-main" + } + ], + "bucketId": "group.00%", + "geometry": { + "aabb": { + "origin": [ + 11.015000343322754, + 48.00600051879883, + 0.20000004768371582 + ], + "size": [ + 0.000699999975040555, + 0.0005499999970197678, + 0.20000000298023224 + ] + }, + "coordinates": [ + [ + [ + 11.015000343322754, + 48.00600051879883, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.00600051879883, + 0.20000004768371582 + ], + [ + 11.015700343297794, + 48.00655051879585, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.00655051879585, + 0.20000004768371582 + ], + [ + 11.015000343322754, + 48.00600051879883, + 0.20000004768371582 + ] + ] + ], + "type": "Polygon" + }, + "id": "Entry.444000777123.group%2E00%25.70", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": true, + "label": "item-070", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-70", + "b-70" + ] + }, + "validity": { + "direction": "COMPLETE", + "geometry": { + "coordinates": [ + [ + 11.015000343322754, + 48.00600051879883, + 0.20000004768371582 + ], + [ + 11.015240669250488, + 48.00617980957031, + 0.20000004768371582 + ] + ], + "geometryName": "delta", + "type": "LineString" + } + }, + "value": "token-0" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "warm" + } + } + }, + "notes": null, + "rank": 2, + "summary": { + "kind": "kind-2", + "score": 0.7 + } + }, + "recordId": 70, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + }, + { + "bucketId": "group.00%", + "geometry": { + "coordinates": [ + [ + 11.016500473022461, + 48.00600051879883, + 0.2999999523162842 + ], + [ + 11.016780853271484, + 48.00611114501953, + 0.6000000238418579 + ] + ], + "type": "MultiPoint" + }, + "id": "Entry.444000777123.group%2E00%25.71", + "layerId": "CollectionLayer", + "mapId": "DatasetAlpha", + "properties": { + "active": false, + "label": "item-071", + "layer": { + "bundle": { + "slotMain": { + "meta": { + "_multimap": true, + "blob": [ + { + "_bytes": true, + "hex": "4142", + "number": 16706 + } + ], + "tag": [ + "a-71", + "b-71" + ] + }, + "value": "token-1" + } + }, + "status": { + "slotAux": { + "flag": false, + "state": "cold" + } + } + }, + "notes": "note-1", + "rank": 3, + "summary": { + "kind": "kind-3", + "score": 0.8 + } + }, + "recordId": 71, + "tileId": 444000777123, + "type": "Feature", + "typeId": "Entry" + } + ], + "geometryAnchor": [ + 11.125, + 48.125, + 2.0 + ], + "mapId": "DatasetAlpha", + "mapgetLayerId": "CollectionLayer", + "mapgetTileId": 444000777123, + "timestamp": 1717245296123456, + "ttl": 60000, + "type": "FeatureCollection" +} diff --git a/test/unit/data/large-geojson-featureset.layer-info.json b/test/unit/data/large-geojson-featureset.layer-info.json new file mode 100644 index 00000000..a69638a2 --- /dev/null +++ b/test/unit/data/large-geojson-featureset.layer-info.json @@ -0,0 +1,33 @@ +{ + "layerId": "CollectionLayer", + "type": "Features", + "featureTypes": [ + { + "name": "Entry", + "uniqueIdCompositions": [ + [ + { + "partId": "tileId", + "datatype": "U64", + "isSynthetic": true + }, + { + "partId": "bucketId", + "datatype": "STR" + }, + { + "partId": "recordId", + "datatype": "U32" + } + ] + ] + } + ], + "stages": 3, + "stageLabels": [ + "draft", + "full", + "delta" + ], + "highFidelityStage": 1 +} diff --git a/test/unit/test-config.cpp b/test/unit/test-config.cpp index 5d77cc9c..16cd67b3 100644 --- a/test/unit/test-config.cpp +++ b/test/unit/test-config.cpp @@ -214,3 +214,50 @@ TEST_CASE("Datasource Config", "[DataSourceConfig]") fs::remove_all(tempDir); DataSourceConfigService::get().end(); } + +TEST_CASE("Datasource enabled flag is handled generically", "[DataSourceConfig]") +{ + auto tempDir = fs::current_path() / test::generateTimestampedDirectoryName("mapget_test_ds_enabled"); + fs::create_directory(tempDir); + auto tempConfigPath = tempDir / "temp_config.yaml"; + + DataSourceConfigService::get().reset(); + DataSourceConfigService::get().registerDataSourceType( + "TestDataSource", + [](const YAML::Node&) -> DataSource::Ptr + { return std::make_shared(); }); + + auto cache = std::make_shared(); + Service service(cache, true); + + { + std::ofstream out(tempConfigPath, std::ios_base::trunc); + out << R"( +sources: + - type: TestDataSource + enabled: false + - type: TestDataSource + enabled: true +)"; + out.flush(); + out.close(); + syncFile(tempConfigPath); + } + + DataSourceConfigService::get().loadConfig(tempConfigPath.string()); + waitForCondition([&service]() { return service.info().size() == 1; }); + + auto stats = DataSourceConfigService::get().getDataSourceConfigStats(); + REQUIRE(stats.configured == 2); + REQUIRE(stats.enabled == 1); + REQUIRE(stats.disabled == 1); + + auto serviceStats = service.getStatistics(); + REQUIRE(serviceStats["datasource-config"]["configured"].get() == 2); + REQUIRE(serviceStats["datasource-config"]["enabled"].get() == 1); + REQUIRE(serviceStats["datasource-config"]["disabled"].get() == 1); + REQUIRE(serviceStats["datasource-config"]["construction-failed"].get() == 0); + + fs::remove_all(tempDir); + DataSourceConfigService::get().end(); +} diff --git a/test/unit/test-geojson-import.cpp b/test/unit/test-geojson-import.cpp new file mode 100644 index 00000000..acaf098b --- /dev/null +++ b/test/unit/test-geojson-import.cpp @@ -0,0 +1,366 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "mapget/model/featureid.h" +#include "mapget/model/featurelayer.h" +#include "mapget/model/json-compare.h" +#include "nlohmann/json.hpp" +#include "simfil/byte-array.h" + +using namespace mapget; +using namespace std::chrono_literals; +using namespace nlohmann::literals; + +namespace +{ + +struct SourceRefSpec +{ + uint32_t bitOffset_; + uint32_t bitSize_; + std::string layerId_; + std::string qualifier_; +}; + +std::shared_ptr makeRoadLayerInfo() +{ + return LayerInfo::fromJson(R"json( + { + "layerId": "RoadLayer", + "type": "Features", + "featureTypes": [ + { + "name": "Road", + "uniqueIdCompositions": [ + [ + {"partId": "tileId", "datatype": "U64", "isSynthetic": true}, + {"partId": "regionId", "datatype": "STR"}, + {"partId": "roadId", "datatype": "U32"} + ] + ] + } + ], + "stages": 3, + "stageLabels": ["Draft", "High-Fi", "ADAS"], + "highFidelityStage": 1 + })json"_json); +} + +std::shared_ptr makeGenericLayerInfo() +{ + return LayerInfo::fromJson(R"json( + { + "layerId": "GeoJsonAny", + "type": "Features", + "featureTypes": [ + { + "name": "AnyFeature", + "uniqueIdCompositions": [[ + {"partId": "tileId", "datatype": "U64", "isSynthetic": true}, + {"partId": "featureIndex", "datatype": "U32", "isSynthetic": true} + ]] + } + ] + })json"_json); +} + +TileFeatureLayer::Ptr makeTile( + uint64_t tileId, + std::shared_ptr const& layerInfo, + std::string const& nodeId = "GeoJsonImportNode", + std::string const& mapId = "GeoJsonImportMap") +{ + return std::make_shared( + TileId(tileId), + nodeId, + mapId, + layerInfo, + std::make_shared(nodeId)); +} + +simfil::ModelNode::Ptr nullNode(TileFeatureLayer& tile) +{ + return tile.resolve( + simfil::ModelNodeAddress{simfil::Model::Null, 1}, + simfil::ScalarValueType{}); +} + +model_ptr makeSourceDataRefs( + TileFeatureLayer& tile, + std::initializer_list refs) +{ + std::vector values; + values.reserve(refs.size()); + for (auto const& spec : refs) { + auto layerId = tile.strings()->emplace(spec.layerId_); + if (!layerId) { + throw std::runtime_error(layerId.error().message); + } + auto qualifier = tile.strings()->emplace(spec.qualifier_); + if (!qualifier) { + throw std::runtime_error(qualifier.error().message); + } + values.push_back(QualifiedSourceDataReference{ + SourceDataAddress::fromBitPosition(spec.bitOffset_, spec.bitSize_), + *layerId, + *qualifier, + }); + } + return tile.newSourceDataReferenceCollection(values); +} + +std::filesystem::path fixturePath(std::string const& fileName) +{ + return std::filesystem::path(__FILE__).parent_path() / "data" / fileName; +} + +[[nodiscard]] nlohmann::json loadJsonFixture(std::string const& fileName) +{ + auto path = fixturePath(fileName); + std::ifstream input(path); + if (!input) { + throw std::runtime_error("Failed to open fixture: " + path.string()); + } + return nlohmann::json::parse(input); +} + +template +void requireExpectedOk(tl::expected const& result) +{ + REQUIRE(result); +} + +} // namespace + +TEST_CASE("Feature ID strings roundtrip escaped string parts", "[FeatureId][GeoJsonImport]") +{ + auto layerInfo = makeRoadLayerInfo(); + + ParsedFeatureId parsed; + std::string error; + REQUIRE(parseFeatureIdString("Road.77.DE%2EBY%25.7", *layerInfo, parsed, &error)); + REQUIRE(parsed.typeId_ == "Road"); + REQUIRE(parsed.keyValuePairs_.size() == 3); + REQUIRE(parsed.keyValuePairs_[0].first == "tileId"); + REQUIRE(std::get(parsed.keyValuePairs_[0].second) == 77); + REQUIRE(parsed.keyValuePairs_[1].first == "regionId"); + REQUIRE(std::get(parsed.keyValuePairs_[1].second) == "DE.BY%"); + REQUIRE(parsed.keyValuePairs_[2].first == "roadId"); + REQUIRE(std::get(parsed.keyValuePairs_[2].second) == 7); + + auto tile = makeTile(77, layerInfo, "FeatureIdNode"); + tile->setIdPrefix({{"tileId", static_cast(77)}, {"regionId", "DE.BY%"}}); + auto feature = tile->newFeature("Road", {{"roadId", 7}}); + REQUIRE(feature->id()->toString() == "Road.77.DE%2EBY%25.7"); + REQUIRE(tile->find("Road.77.DE%2EBY%25.7")); +} + +TEST_CASE("TileFeatureLayer strict GeoJSON import roundtrips mapget JSON", "[GeoJsonImport]") +{ + auto layerInfo = makeRoadLayerInfo(); + auto tile = makeTile(77, layerInfo, "StrictImportNode", "StrictImportMap"); + + tile->setIdPrefix({{"tileId", static_cast(77)}, {"regionId", "DE.BY%"}}); + tile->setGeometryAnchor({11.3, 48.0, 5.0}); + tile->setTimestamp(std::chrono::system_clock::time_point{1714348800s} + 123456us); + tile->setTtl(2500ms); + tile->setError(std::optional{"partially degraded"}); + tile->setErrorCode(std::optional{206}); + + auto roadA = tile->newFeature("Road", {{"roadId", 7}}); + auto roadALine = roadA->geom()->newGeometry(GeomType::Line, 3, true); + roadALine->append({11.3, 48.0, 0.0}); + roadALine->append({11.31, 48.01, 0.0}); + roadALine->append({11.32, 48.015, 0.0}); + roadALine->setStage(2); + roadALine->setSourceDataReferences(makeSourceDataRefs( + *tile, + {{10, 20, "road-src", "centerline"}})); + + auto roadAMesh = roadA->geom()->newGeometry(GeomType::Mesh, 3, true); + roadAMesh->append({11.3, 48.0, 0.0}); + roadAMesh->append({11.3, 48.002, 0.0}); + roadAMesh->append({11.302, 48.001, 0.0}); + roadAMesh->setStage(2); + + roadA->setSourceDataReferences(makeSourceDataRefs( + *tile, + {{30, 40, "road-src", "feature"}})); + requireExpectedOk(roadA->attributes()->addField("speedLimit", int64_t{80})); + requireExpectedOk(roadA->attributes()->addField("optionalNote", nullNode(*tile))); + + auto restrictions = roadA->attributeLayers()->newLayer("restrictions"); + auto turn = restrictions->newAttribute("turn"); + auto turnMeta = tile->newObject(3, true); + requireExpectedOk(turnMeta->addField("tag", "left")); + requireExpectedOk(turnMeta->addField("tag", "through")); + requireExpectedOk(turnMeta->addField("blob", tile->newValue(simfil::ByteArray{"AB"}))); + requireExpectedOk(turn->addField("meta", turnMeta)); + turn->setSourceDataReferences(makeSourceDataRefs( + *tile, + {{50, 12, "rules-src", "attribute"}})); + + auto roadB = tile->newFeature("Road", {{"roadId", 9}}); + auto roadBLine = roadB->geom()->newGeometry(GeomType::Line, 2, true); + roadBLine->append({11.32, 48.015, 0.0}); + roadBLine->append({11.33, 48.02, 0.0}); + + turn->validity()->newFeatureTransition( + roadA, + Validity::End, + roadB, + Validity::Start, + 3, + Validity::Positive); + + auto externalRoadReference = tile->newFeatureId( + "Road", + { + {"tileId", static_cast(77)}, + {"regionId", "DE.BY%"}, + {"roadId", 11}, + }, + "ValidationMap"); + auto clearance = restrictions->newAttribute("clearance"); + requireExpectedOk(clearance->addField("value", double{3.5})); + clearance->validity()->newFeatureId(externalRoadReference, Validity::Negative); + + auto relation = tile->newRelation( + "connectedTo", + tile->newFeatureId( + "Road", + { + {"tileId", static_cast(77)}, + {"regionId", "DE.BY%"}, + {"roadId", 9}, + }, + "ValidationMap")); + relation->setSourceDataReferences(makeSourceDataRefs( + *tile, + {{70, 8, "rules-src", "relation"}})); + relation->sourceValidity()->newRange( + Validity::RelativeLengthOffset, + 0.25, + 0.75, + 2, + Validity::Positive); + relation->targetValidity()->newPoint( + Point{11.32, 48.015, 0.0}, + std::nullopt, + Validity::Negative); + roadA->addRelation(relation); + + auto roadC = tile->newFeature("Road", {{"roadId", 11}}); + auto roadCAabb = roadC->geom()->newGeometry(GeomType::AABB, 2, true); + roadCAabb->setAabb({11.4, 48.1, 1.0}, {0.01, 0.02, 0.5}); + + auto originalJson = tile->toJson(); + REQUIRE(originalJson["features"][0]["id"] == "Road.77.DE%2EBY%25.7"); + REQUIRE(originalJson["features"][0]["geometry"]["type"] == "GeometryCollection"); + REQUIRE(originalJson["features"][0]["relations"][0]["target"] == nlohmann::json{ + {"id", "Road.77.DE%2EBY%25.9"}, + {"mapId", "ValidationMap"}, + }); + REQUIRE( + originalJson["features"][0]["properties"]["layer"]["restrictions"]["clearance"]["validity"]["featureId"] == + nlohmann::json{ + {"id", "Road.77.DE%2EBY%25.11"}, + {"mapId", "ValidationMap"}, + }); + + auto imported = makeTile(77, layerInfo, "StrictImportNode", "StrictImportMap"); + REQUIRE_NOTHROW(imported->fromJson(originalJson)); + REQUIRE(imported->toJson() == originalJson); + REQUIRE(imported->find("Road.77.DE%2EBY%25.7")); + REQUIRE(imported->find("Road.77.DE%2EBY%25.9")); +} + +TEST_CASE("TileFeatureLayer best-effort GeoJSON import shares the same pipeline", "[GeoJsonImport]") +{ + auto layerInfo = makeGenericLayerInfo(); + auto tile = makeTile(37392110387213ULL, layerInfo, "BestEffortNode"); + + auto input = R"json( + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [[11.3, 48.0, 0.0], [11.31, 48.01, 1.0]], + [[11.31, 48.01, 1.0], [11.32, 48.02, 2.0]] + ] + }, + "properties": { + "name": "Main", + "restrictions": { + "turn": { + "value": "left", + "validity": { + "direction": "POSITIVE" + } + } + } + } + } + ] + })json"_json; + + REQUIRE_NOTHROW(tile->fromJson( + input, + GeoJsonImportOptions{ + .strict_ = false, + .fallbackFeatureType_ = "AnyFeature", + .objectPropertiesAsAttributeLayers_ = true, + })); + + auto output = tile->toJson(); + REQUIRE(output["features"].size() == 1); + REQUIRE(output["features"][0]["id"] == "AnyFeature.37392110387213.0"); + REQUIRE(output["features"][0]["geometry"]["type"] == "GeometryCollection"); + REQUIRE(output["features"][0]["properties"]["name"] == "Main"); + REQUIRE(output["features"][0]["properties"]["layer"]["restrictions"]["turn"]["value"] == "left"); + REQUIRE( + output["features"][0]["properties"]["layer"]["restrictions"]["turn"]["validity"]["direction"] == + "POSITIVE"); +} + +TEST_CASE("Large sanitized GeoJSON fixture roundtrips", "[GeoJsonImport][Fixture]") +{ + auto layerInfoJson = loadJsonFixture("large-geojson-featureset.layer-info.json"); + auto featureCollectionJson = loadJsonFixture("large-geojson-featureset.feature-collection.json"); + auto layerInfo = LayerInfo::fromJson(layerInfoJson); + + REQUIRE(featureCollectionJson.at("mapgetLayerId").get() == layerInfo->layerId_); + REQUIRE(featureCollectionJson.at("features").size() >= 64); + + auto tile = makeTile( + featureCollectionJson.at("mapgetTileId").get(), + layerInfo, + "FixtureImportNode", + featureCollectionJson.at("mapId").get()); + + REQUIRE_NOTHROW(tile->fromJson(featureCollectionJson)); + + auto roundTrip = tile->toJson(); + std::vector errors; + auto const matches = compareJsonWithTolerance( + featureCollectionJson, + roundTrip, + 1e-5, + &errors); + INFO(nlohmann::json::diff(featureCollectionJson, roundTrip).dump()); + INFO(formatJsonComparisonErrors(errors)); + REQUIRE(matches); + + REQUIRE(tile->find("Entry.444000777123.group%2E00%25.0")); + REQUIRE(tile->find("Entry.444000777123.group%2E00%25.71")); +} diff --git a/test/unit/test-geojsonsource.cpp b/test/unit/test-geojsonsource.cpp index b41c6d13..f7107640 100644 --- a/test/unit/test-geojsonsource.cpp +++ b/test/unit/test-geojsonsource.cpp @@ -2,9 +2,12 @@ #include #include #include +#include +#include #include "geojsonsource/geojsonsource.h" #include "mapget/model/featurelayer.h" +#include using namespace mapget; @@ -43,6 +46,12 @@ auto sampleGeoJson2 = R"json({"type": "FeatureCollection", "features": [{ "type": "Feature" }]})json"; +std::string testEndpointBaseUrl() +{ + static constexpr char cleartextScheme[] = {'h', 't', 't', 'p', '\0'}; + return fmt::format("{}://{}", std::string_view(cleartextScheme, 4), "geojson-endpoint.test"); +} + std::filesystem::path createTempDir() { auto now = std::chrono::system_clock::now(); @@ -61,6 +70,7 @@ std::filesystem::path createTempDir() void writeFile(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); std::ofstream file(path); file << content; file.close(); @@ -407,4 +417,152 @@ TEST_CASE("GeoJsonSource", "[GeoJsonSource]") std::filesystem::remove_all(tempDir); } + + SECTION("Explicit datasource info file enables template-based folder loading") + { + auto tempDir = createTempDir(); + + writeFile(tempDir / "Road" / (std::to_string(largeTileId) + ".geojson"), sampleGeoJson); + writeFile(tempDir / "Lane" / (std::to_string(largeTileId) + ".geojson"), sampleGeoJson2); + writeFile(tempDir / "info.yaml", fmt::format(R"yaml( +mapId: ExplicitGeoJson +layers: + Road: + featureTypes: + - name: RoadFeature + uniqueIdCompositions: + - - partId: tileId + datatype: U64 + - partId: featureIndex + datatype: U32 + coverage: + - {} + Lane: + featureTypes: + - name: LaneFeature + uniqueIdCompositions: + - - partId: tileId + datatype: U64 + - partId: featureIndex + datatype: U32 + coverage: + - {} +)yaml", largeTileId, largeTileId)); + + geojsonsource::GeoJsonSource source( + tempDir.string(), + geojsonsource::GeoJsonSourceOptions{ + .withAttrLayers = false, + .tilePathTemplate = "{layerId}/{tileId}.geojson", + .dataSourceInfoLocation = (tempDir / "info.yaml").string()}); + + auto info = source.info(); + REQUIRE_FALSE(source.hasManifest()); + REQUIRE(info.mapId_ == "ExplicitGeoJson"); + REQUIRE(info.getLayer("Road") != nullptr); + REQUIRE(info.getLayer("Lane") != nullptr); + + auto strings = std::make_shared(info.nodeId_); + auto roadTile = std::make_shared( + TileId(largeTileId), + info.nodeId_, + info.mapId_, + info.getLayer("Road"), + strings); + REQUIRE_NOTHROW(source.fill(roadTile)); + REQUIRE(roadTile->numRoots() > 0); + + auto laneTile = std::make_shared( + TileId(largeTileId), + info.nodeId_, + info.mapId_, + info.getLayer("Lane"), + strings); + REQUIRE_NOTHROW(source.fill(laneTile)); + REQUIRE(laneTile->numRoots() > 0); + + std::filesystem::remove_all(tempDir); + } + + SECTION("GeoJsonEndpoint loads tiles over HTTP with and without datasource info") + { + auto infoYaml = fmt::format(R"yaml( +mapId: RemoteGeoJson +layers: + Road: + featureTypes: + - name: RoadFeature + uniqueIdCompositions: + - - partId: tileId + datatype: U64 + - partId: featureIndex + datatype: U32 + coverage: + - {} +)yaml", largeTileId); + + auto const endpointBaseUrl = testEndpointBaseUrl(); + auto responses = std::make_shared>( + std::unordered_map{ + {fmt::format("{}/info.yaml", endpointBaseUrl), infoYaml}, + {fmt::format("{}/tiles/Road/{}.geojson", endpointBaseUrl, largeTileId), sampleGeoJson}, + {fmt::format("{}/{}.geojson", endpointBaseUrl, largeTileId), sampleGeoJson}, + }); + auto fetchText = [responses](std::string const& url) -> std::string { + auto it = responses->find(url); + if (it == responses->end()) { + throw std::runtime_error(fmt::format("Unexpected GeoJsonEndpoint test URL: {}", url)); + } + return it->second; + }; + + geojsonsource::GeoJsonEndpointSource source({ + .baseUrl = fmt::format("{}/tiles", endpointBaseUrl), + .withAttrLayers = false, + .tileUrlTemplate = "{layerId}/{tileId}.geojson", + .dataSourceInfoLocation = fmt::format("{}/info.yaml", endpointBaseUrl), + .fetchText = fetchText, + }); + + auto info = source.info(); + REQUIRE(info.mapId_ == "RemoteGeoJson"); + auto roadLayer = info.getLayer("Road"); + REQUIRE(roadLayer != nullptr); + + auto strings = std::make_shared(info.nodeId_); + auto roadTile = std::make_shared( + TileId(largeTileId), + info.nodeId_, + info.mapId_, + roadLayer, + strings); + REQUIRE_NOTHROW(source.fill(roadTile)); + REQUIRE(roadTile->numRoots() > 0); + REQUIRE_FALSE(roadTile->error().has_value()); + + geojsonsource::GeoJsonEndpointSource fallbackSource({ + .baseUrl = endpointBaseUrl, + .withAttrLayers = false, + .mapId = "FallbackEndpoint", + .tileUrlTemplate = "{tileId}.geojson", + .fetchText = fetchText, + }); + + auto fallbackInfo = fallbackSource.info(); + REQUIRE(fallbackInfo.mapId_ == "FallbackEndpoint"); + auto anyLayer = fallbackInfo.getLayer("GeoJsonAny"); + REQUIRE(anyLayer != nullptr); + REQUIRE(anyLayer->coverage_.empty()); + + auto fallbackStrings = std::make_shared(fallbackInfo.nodeId_); + auto tile = std::make_shared( + TileId(largeTileId), + fallbackInfo.nodeId_, + fallbackInfo.mapId_, + anyLayer, + fallbackStrings); + REQUIRE_NOTHROW(fallbackSource.fill(tile)); + REQUIRE(tile->numRoots() > 0); + REQUIRE_FALSE(tile->error().has_value()); + } } diff --git a/test/unit/test-http-datasource.cpp b/test/unit/test-http-datasource.cpp index 54948884..53a3403f 100644 --- a/test/unit/test-http-datasource.cpp +++ b/test/unit/test-http-datasource.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -692,6 +693,24 @@ TEST_CASE("HttpDataSource", "[HttpDataSource]") static_cast(RequestStatus::Unauthorized)); } + // WebSocket tiles: include noDataSourceReason in status payload when available. + { + auto req = nlohmann::json::object({ + {"requests", nlohmann::json::array({nlohmann::json::object({ + {"mapId", "UnknownMap"}, + {"layerId", "WayLayer"}, + {"tileIds", nlohmann::json::array({1234})}, + })})}, + }).dump(); + + auto [status, wsTileCount] = runWsTilesRequest(true, req); + REQUIRE(wsTileCount == 0); + REQUIRE(status["requests"].size() == 1); + REQUIRE(status["requests"][0]["status"].get() == + static_cast(RequestStatus::NoDataSource)); + REQUIRE(status["requests"][0]["noDataSourceReason"].get() == "missingMapOrLayer"); + } + // WebSocket tiles: invalid request stays on the same connection, then succeeds. { WsTilesClient wsClient(service.port(), layerInfo); @@ -778,11 +797,11 @@ TEST_CASE("Configuration Endpoint Tests", "[Configuration]") SyncHttpClient cli("127.0.0.1", service.port()); - auto tempDir = fs::temp_directory_path() / test::generateTimestampedDirectoryName("mapget_test_http_config"); + auto tempDir = fs::current_path() / test::generateTimestampedDirectoryName("mapget_test_http_config"); fs::create_directory(tempDir); auto tempConfigPath = tempDir / "temp_config.yaml"; + auto tempMissingConfigPath = tempDir / "missing_config.yaml"; - // Set up the config file. DataSourceConfigService::get().reset(); struct ConfigWatchGuard { ~ConfigWatchGuard() { DataSourceConfigService::get().end(); } @@ -790,6 +809,13 @@ TEST_CASE("Configuration Endpoint Tests", "[Configuration]") struct SchemaPatchGuard { ~SchemaPatchGuard() { DataSourceConfigService::get().setDataSourceConfigSchemaPatch(nlohmann::json::object()); } } schemaPatchGuard; + struct EndpointToggleGuard { + ~EndpointToggleGuard() + { + setGetConfigEndpointEnabled(true); + setPostConfigEndpointEnabled(false); + } + } endpointToggleGuard; auto schemaPatch = nlohmann::json::parse(R"( { @@ -803,23 +829,21 @@ TEST_CASE("Configuration Endpoint Tests", "[Configuration]") )"); DataSourceConfigService::get().setDataSourceConfigSchemaPatch(schemaPatch); - SECTION("Get Configuration - Config File Not Found") - { - DataSourceConfigService::get().loadConfig(tempConfigPath.string()); - auto [result, res] = cli.get("/config"); - REQUIRE(result == drogon::ReqResult::Ok); - REQUIRE(res != nullptr); - REQUIRE(res->statusCode() == drogon::k404NotFound); - REQUIRE(std::string(res->body()) == "The server does not have a config file."); - } + DataSourceConfigService::get().registerPublicConfigSection( + "publicConfig", + [](YAML::Node const& fullConfig) -> nlohmann::json { + auto section = fullConfig["publicConfig"]; + if (!section || !section.IsMap() || section.size() == 0) { + return nlohmann::json::object(); + } + return yamlToJson(section, false); + }); - // Create config file for tests that need it - { + auto writeConfigFile = [&](std::string const& contents) { std::ofstream configFile(tempConfigPath); - configFile << "sources: []\nhttp-settings: [{'password': 'hunter2'}]"; + configFile << contents; configFile.flush(); configFile.close(); - #ifndef _WIN32 int fd = open(tempConfigPath.c_str(), O_RDONLY); if (fd != -1) { @@ -828,49 +852,116 @@ TEST_CASE("Configuration Endpoint Tests", "[Configuration]") } #endif std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } + }; + auto getConfigPayload = [&]() { + auto [result, res] = cli.get("/config"); + REQUIRE(result == drogon::ReqResult::Ok); + REQUIRE(res != nullptr); + REQUIRE(res->statusCode() == drogon::k200OK); + return nlohmann::json::parse(std::string(res->body())); + }; + + auto requireUnavailablePayload = [&]( + std::string_view reason, + bool expectPublicConfig = false, + bool expectReadOnly = true, + bool expectSchema = false) { + auto payload = getConfigPayload(); + REQUIRE(payload["datasourceConfigUnavailable"].get() == true); + REQUIRE(payload["datasourceConfigUnavailableReason"].get() == reason); + REQUIRE(payload["model"] == nlohmann::json::object()); + REQUIRE(payload["readOnly"].get() == expectReadOnly); + if (expectSchema) { + REQUIRE(payload["schema"].is_object()); + REQUIRE(payload["schema"].empty() == false); + } + else { + REQUIRE(payload["schema"] == nlohmann::json::object()); + } + if (expectPublicConfig) { + REQUIRE(payload["publicConfig"]["featureFlag"].get() == true); + } + else { + REQUIRE(payload["publicConfig"] == nlohmann::json::object()); + } + }; + + writeConfigFile( + "sources: []\n" + "http-settings: [{'password': 'hunter2'}]\n" + "publicConfig:\n" + " featureFlag: true\n" + "erdblick:\n" + " keepMe: true\n"); DataSourceConfigService::get().loadConfig(tempConfigPath.string()); std::this_thread::sleep_for(std::chrono::milliseconds(500)); - SECTION("Get Configuration - Not allowed") + SECTION("Get Configuration - Config File Missing") + { + DataSourceConfigService::get().loadConfig(tempMissingConfigPath.string()); + requireUnavailablePayload("configFileMissing"); + } + + SECTION("Get Configuration - Endpoint disabled returns flagged 200") { setGetConfigEndpointEnabled(false); - auto [result, res] = cli.get("/config"); - REQUIRE(result == drogon::ReqResult::Ok); - REQUIRE(res != nullptr); - REQUIRE(res->statusCode() == drogon::k403Forbidden); + requireUnavailablePayload("getConfigDisabled", true); + } + + SECTION("Get Configuration - Endpoint hidden but POST enabled returns writable empty model") + { + setGetConfigEndpointEnabled(false); + setPostConfigEndpointEnabled(true); + requireUnavailablePayload("getConfigDisabled", true, false, true); } SECTION("Get Configuration - No Config File Path Set") { - setGetConfigEndpointEnabled(true); DataSourceConfigService::get().loadConfig(""); - auto [result, res] = cli.get("/config"); - REQUIRE(result == drogon::ReqResult::Ok); - REQUIRE(res != nullptr); - REQUIRE(res->statusCode() == drogon::k404NotFound); - REQUIRE(std::string(res->body()) == - "The config file path is not set. Check the server configuration."); + requireUnavailablePayload("configPathUnset"); } - SECTION("Get Configuration - Success") + SECTION("Get Configuration - Validation failure is flagged") { - setGetConfigEndpointEnabled(true); - auto [result, res] = cli.get("/config"); - REQUIRE(result == drogon::ReqResult::Ok); - REQUIRE(res != nullptr); - REQUIRE(res->statusCode() == drogon::k200OK); + writeConfigFile("sources: []\n"); + DataSourceConfigService::get().loadConfig(tempConfigPath.string()); + requireUnavailablePayload("configValidationFailed"); + } - auto body = std::string(res->body()); - REQUIRE(body.find("sources") != std::string::npos); - REQUIRE(body.find("http-settings") != std::string::npos); + SECTION("Get Configuration - Success") + { + auto payload = getConfigPayload(); + REQUIRE(payload["datasourceConfigUnavailable"].get() == false); + REQUIRE(payload["datasourceConfigUnavailableReason"].is_null()); + REQUIRE(payload["readOnly"].get() == true); + REQUIRE(payload["model"].contains("sources")); + REQUIRE(payload["model"].contains("http-settings")); + REQUIRE(payload["model"].contains("publicConfig") == false); + REQUIRE(payload["publicConfig"]["featureFlag"].get() == true); + + auto body = payload.dump(); REQUIRE(body.find("hunter2") == std::string::npos); REQUIRE( body.find("MASKED:0:f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7") != std::string::npos); } + SECTION("Get Configuration - Public section serializer exceptions are tolerated") + { + struct ThrowingSectionError : std::runtime_error { + using std::runtime_error::runtime_error; + }; + + DataSourceConfigService::get().registerPublicConfigSection( + "throwingSection", + [](YAML::Node const&) -> nlohmann::json { throw ThrowingSectionError("boom"); }); + + auto payload = getConfigPayload(); + REQUIRE(payload["datasourceConfigUnavailable"].get() == false); + REQUIRE(payload["throwingSection"] == nlohmann::json::object()); + } + SECTION("Post Configuration - Not Enabled") { setPostConfigEndpointEnabled(false); @@ -910,7 +1001,7 @@ TEST_CASE("Configuration Endpoint Tests", "[Configuration]") REQUIRE(std::string(res->body()).starts_with("Validation failed")); } - SECTION("Post Configuration - Valid JSON Config") + SECTION("Post Configuration - Valid JSON Config preserves top-level erdblick") { setPostConfigEndpointEnabled(true); std::string newConfig = R"({ @@ -929,8 +1020,10 @@ TEST_CASE("Configuration Endpoint Tests", "[Configuration]") configContentStream << config.rdbuf(); auto configContent = configContentStream.str(); REQUIRE(configContent.find("hunter2") != std::string::npos); + REQUIRE(configContent.find("erdblick") != std::string::npos); + REQUIRE(configContent.find("keepMe") != std::string::npos); } DataSourceConfigService::get().end(); - fs::remove(tempConfigPath); + fs::remove_all(tempDir); } diff --git a/test/unit/test-model-geometry.cpp b/test/unit/test-model-geometry.cpp index 8c7176bc..760e5416 100644 --- a/test/unit/test-model-geometry.cpp +++ b/test/unit/test-model-geometry.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "mapget/model/featurelayer.h" #include "mapget/model/stream.h" @@ -360,6 +361,123 @@ TEST_CASE("GeometryCollection Multiple Geometries", "[geom.collection.multiple]" } } +TEST_CASE("AABB geometry roundtrip and JSON exposure", "[geom.aabb]") +{ + auto tile = makeTile(); + auto feature = tile->newFeature("Way", {{"wayId", 101}}); + auto aabb = feature->geom()->newGeometry(GeomType::AABB); + aabb->setAabb({1.0, 2.0, 3.0}, {10.0, 20.0, 30.0}); + + REQUIRE(aabb->geomType() == GeomType::AABB); + REQUIRE(aabb->numPoints() == 2); + REQUIRE(aabb->aabbOrigin() == Point{1.0, 2.0, 3.0}); + REQUIRE(aabb->aabbSize() == Point{10.0, 20.0, 30.0}); + REQUIRE(aabb->pointAt(0) == Point{1.0, 2.0, 3.0}); + REQUIRE(aabb->pointAt(1) == Point{10.0, 20.0, 30.0}); + + auto json = feature->toJson(); + REQUIRE(json["geometry"]["type"] == "Polygon"); + REQUIRE(json["geometry"]["coordinates"].size() == 1); + REQUIRE(json["geometry"]["coordinates"][0].size() == 5); + REQUIRE(json["geometry"]["coordinates"][0][0] == nlohmann::json::array({1.0, 2.0, 3.0})); + REQUIRE(json["geometry"]["coordinates"][0][2] == nlohmann::json::array({11.0, 22.0, 3.0})); + REQUIRE(json["geometry"]["aabb"]["origin"] == nlohmann::json::array({1.0, 2.0, 3.0})); + REQUIRE(json["geometry"]["aabb"]["size"] == nlohmann::json::array({10.0, 20.0, 30.0})); + auto geometryNode = asModelNode(feature).get(StringPool::GeometryStr); + REQUIRE(geometryNode); + REQUIRE(geometryNode->toJson() == json["geometry"]); + + std::stringstream tileBytes; + tile->write(tileBytes); + auto serializedTile = tileBytes.str(); + std::vector tileBuffer(serializedTile.begin(), serializedTile.end()); + auto deserializedTile = std::make_shared( + tileBuffer, + [&](auto&&, auto&&) { + return tile->layerInfo(); + }, + [&](auto&&) { + return tile->strings(); + }); + + auto roundTrippedFeature = deserializedTile->at(0); + auto roundTrippedAabb = + roundTrippedFeature->geomOrNull()->geometryOfTypeAtPreferredStage(GeomType::AABB, 0U); + REQUIRE(roundTrippedAabb); + REQUIRE(roundTrippedAabb->aabbOrigin() == Point{1.0, 2.0, 3.0}); + REQUIRE(roundTrippedAabb->aabbSize() == Point{10.0, 20.0, 30.0}); +} + +TEST_CASE("GltfNodeIndex geometry roundtrip and JSON exposure", "[geom.gltf]") +{ + auto tile = makeTile(); + tile->setGlbAttachment("city.glb", {0x67, 0x6c, 0x54, 0x46}); + auto feature = tile->newFeature("Way", {{"wayId", 202}}); + auto first = feature->geom()->newGeometry(GeomType::GltfNodeIndex); + first->setGltfNodeIndex(17); + first->setGltfNodeBounds({1.0, 2.0, 3.0}, {10.0, 20.0, 30.0}); + auto second = feature->geom()->newGeometry(GeomType::GltfNodeIndex); + second->setGltfNodeIndex(23); + second->setGltfNodeBounds({4.0, 5.0, 6.0}, {40.0, 50.0, 60.0}); + + REQUIRE(first->geomType() == GeomType::GltfNodeIndex); + REQUIRE(first->gltfNodeIndex() == 17); + REQUIRE(first->gltfNodeAabbOrigin() == Point{1.0, 2.0, 3.0}); + REQUIRE(first->gltfNodeAabbSize() == Point{10.0, 20.0, 30.0}); + REQUIRE(second->gltfNodeIndex() == 23); + REQUIRE(second->gltfNodeAabbOrigin() == Point{4.0, 5.0, 6.0}); + REQUIRE(second->gltfNodeAabbSize() == Point{40.0, 50.0, 60.0}); + second->setGltfNodeIndex(1U << 24); + REQUIRE(second->gltfNodeIndex() == (1U << 24)); + REQUIRE_THROWS(second->setGltfNodeIndex((1U << 24) + 1U)); + second->setGltfNodeIndex(23); + REQUIRE(first->numPoints() == 0); + REQUIRE_THROWS(first->pointAt(0)); + REQUIRE(feature->geomOrNull()->numGeometries() == 2); + + auto json = feature->toJson(); + REQUIRE(json["geometry"]["type"] == "GeometryCollection"); + REQUIRE(json["geometry"]["geometries"].size() == 2); + REQUIRE(json["geometry"]["geometries"][0]["type"] == "Polygon"); + REQUIRE(json["geometry"]["geometries"][0]["gltfNodeIndex"] == 17); + REQUIRE(json["geometry"]["geometries"][0]["coordinates"][0][0] == + nlohmann::json::array({1.0, 2.0, 3.0})); + REQUIRE(json["geometry"]["geometries"][0]["coordinates"][0][2] == + nlohmann::json::array({11.0, 22.0, 3.0})); + REQUIRE(json["geometry"]["geometries"][1]["gltfNodeIndex"] == 23); + auto geometryNode = asModelNode(feature).get(StringPool::GeometryStr); + REQUIRE(geometryNode); + REQUIRE(geometryNode->toJson() == json["geometry"]); + + std::stringstream tileBytes; + tile->write(tileBytes); + auto serializedTile = tileBytes.str(); + std::vector tileBuffer(serializedTile.begin(), serializedTile.end()); + auto deserializedTile = std::make_shared( + tileBuffer, + [&](auto&&, auto&&) { + return tile->layerInfo(); + }, + [&](auto&&) { + return tile->strings(); + }); + + auto roundTrippedFeature = deserializedTile->at(0); + REQUIRE(roundTrippedFeature->geomOrNull()->numGeometries() == 2); + size_t geometryIndex = 0; + roundTrippedFeature->geomOrNull()->forEachGeometry([&](model_ptr const& geometry) { + REQUIRE(geometry->geomType() == GeomType::GltfNodeIndex); + REQUIRE(geometry->gltfNodeIndex() == (geometryIndex == 0 ? 17U : 23U)); + REQUIRE(geometry->gltfNodeAabbOrigin() == + (geometryIndex == 0 ? Point{1.0, 2.0, 3.0} : Point{4.0, 5.0, 6.0})); + REQUIRE(geometry->gltfNodeAabbSize() == + (geometryIndex == 0 ? Point{10.0, 20.0, 30.0} : Point{40.0, 50.0, 60.0})); + ++geometryIndex; + return true; + }); + REQUIRE(geometryIndex == 2); +} + TEST_CASE("Feature Geometry Direct Storage Upgrade", "[geom.collection][feature]") { auto modelPool = makeTile(); diff --git a/test/unit/test-model.cpp b/test/unit/test-model.cpp index 6caaac1f..f104385e 100644 --- a/test/unit/test-model.cpp +++ b/test/unit/test-model.cpp @@ -182,6 +182,10 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]") auto featureForId2 = tile->newFeatureId( "Way", {{"wayIdI32", -42}, {"wayIdI64", -84}, {"wayIdUUID128", "0123456789abcdef"}}); + auto externalFeatureId = tile->newFeatureId( + "Way", + {{"wayIdU32", 7}, {"wayIdU64", 11}, {"wayIdUUID128", "fedcba9876543210"}}, + "ValidationMap"); SECTION("firstGeometry") { @@ -232,6 +236,18 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]") REQUIRE(std::get(keyValuePairs[2].second) == "0123456789abcdef"); } + SECTION("Detached feature IDs preserve optional external map IDs") + { + REQUIRE(featureForId1->toJson() == nlohmann::json("Way.42.84.0123456789abcdef")); + REQUIRE(externalFeatureId->toString() == "Way.7.11.fedcba9876543210"); + REQUIRE(externalFeatureId->mapId() == "ValidationMap"); + REQUIRE(externalFeatureId->externalMapId() == std::optional{"ValidationMap"}); + REQUIRE(externalFeatureId->toJson() == nlohmann::json{ + {"id", "Way.7.11.fedcba9876543210"}, + {"mapId", "ValidationMap"}, + }); + } + SECTION("Evaluate simfil filter") { REQUIRE(feature1->evaluate("**.mozzarella.smell").value().toString() == "neutral"); @@ -274,6 +290,8 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]") SECTION("Serialization") { + tile->setGlbAttachment("city.glb", {0x67, 0x6c, 0x54, 0x46}); + std::stringstream tileBytes; tile->write(tileBytes); auto serializedTile = tileBytes.str(); @@ -302,6 +320,9 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]") REQUIRE(deserializedTile->ttl() == tile->ttl()); REQUIRE(deserializedTile->mapVersion() == tile->mapVersion()); REQUIRE(deserializedTile->info() == tile->info()); + REQUIRE(deserializedTile->glbAttachment() != nullptr); + REQUIRE(deserializedTile->glbAttachment()->name_ == "city.glb"); + REQUIRE(deserializedTile->glbAttachment()->bytes_ == std::vector({0x67, 0x6c, 0x54, 0x46})); REQUIRE(deserializedTile->strings() == tile->strings()); for (auto feature : *deserializedTile) { @@ -319,6 +340,15 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]") REQUIRE(std::get(deserializedKeyValuePairs[1].second) == 84); REQUIRE(deserializedKeyValuePairs[2].first == "wayIdUUID128"); REQUIRE(std::get(deserializedKeyValuePairs[2].second) == "0123456789abcdef"); + + auto deserializedExternalFeatureId = deserializedTile->resolve( + simfil::ModelNodeAddress{TileFeatureLayer::ColumnId::ExternalFeatureIds, 2}); + REQUIRE(deserializedExternalFeatureId); + REQUIRE(deserializedExternalFeatureId->toString() == "Way.7.11.fedcba9876543210"); + REQUIRE(deserializedExternalFeatureId->mapId() == "ValidationMap"); + REQUIRE( + deserializedExternalFeatureId->externalMapId() == + std::optional{"ValidationMap"}); } SECTION("Stream") @@ -327,6 +357,7 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]") // but expect the Fields object to be sent only once. // Then we add another feature with a yet unseen field, send it, // and expect an update for the fields dictionary to be sent along. + tile->setGlbAttachment("city.glb", {0x67, 0x6c, 0x54, 0x46}); auto messageCount = 0; std::stringstream byteStream; @@ -378,6 +409,11 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]") REQUIRE(readTiles[0]->numRoots() == 2); REQUIRE(readTiles[1]->numRoots() == 2); REQUIRE(readTiles[2]->numRoots() == 3); + REQUIRE(readTiles[0]->glbAttachment() != nullptr); + REQUIRE(readTiles[1]->glbAttachment() != nullptr); + REQUIRE(readTiles[2]->glbAttachment() != nullptr); + REQUIRE(readTiles[2]->glbAttachment()->name_ == "city.glb"); + REQUIRE(readTiles[2]->glbAttachment()->bytes_ == std::vector({0x67, 0x6c, 0x54, 0x46})); } SECTION("Find") @@ -408,6 +444,7 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]") { // Set TTL tile->setTtl(std::chrono::milliseconds(3600000)); + tile->setGlbAttachment("city.glb", {0x67, 0x6c, 0x54, 0x46}); auto json = tile->toJson(); @@ -418,19 +455,20 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]") REQUIRE(json["mapId"] == "Tropico"); REQUIRE(json["mapgetLayerId"] == "WayLayer"); - // Verify idPrefix - REQUIRE(json.contains("idPrefix")); - REQUIRE(json["idPrefix"]["areaId"] == "TheBestArea"); - - // Verify timestamp is ISO 8601 format - REQUIRE(json["timestamp"].is_string()); - std::string timestamp = json["timestamp"]; - REQUIRE(timestamp.find("T") != std::string::npos); - REQUIRE(timestamp.back() == 'Z'); + // Verify timestamp stays in the binary microsecond representation. + REQUIRE(json["timestamp"].is_number_integer()); + REQUIRE(json["timestamp"].get() == + std::chrono::duration_cast( + tile->timestamp().time_since_epoch()).count()); // Verify TTL REQUIRE(json["ttl"] == 3600000); + REQUIRE(json["glbAttachment"].is_object()); + REQUIRE(json["glbAttachment"]["name"] == "city.glb"); + REQUIRE(json["glbAttachment"]["mimeType"] == "model/gltf-binary"); + REQUIRE(json["glbAttachment"]["sizeBytes"] == 4); + // Verify features array exists REQUIRE(json["features"].is_array()); REQUIRE(json["features"].size() == 2); // feature0 and feature1 @@ -439,6 +477,21 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]") REQUIRE(!json.contains("error")); } + SECTION("GLB attachment can be replaced") + { + tile->setGlbAttachment("city.glb", {0x67, 0x6c, 0x54, 0x46}); + REQUIRE(tile->glbAttachment() != nullptr); + REQUIRE(tile->glbAttachment()->name_ == "city.glb"); + + tile->setGlbAttachment("city-updated.glb", {0x01, 0x02}); + REQUIRE(tile->glbAttachment() != nullptr); + REQUIRE(tile->glbAttachment()->name_ == "city-updated.glb"); + REQUIRE(tile->glbAttachment()->bytes_ == std::vector({0x01, 0x02})); + + tile->clearGlbAttachment(); + REQUIRE(tile->glbAttachment() == nullptr); + } + SECTION("toJson with error information") { // Set error message and code @@ -764,7 +817,9 @@ TEST_CASE("Single-entry validity collections are exposed as singular nodes", "[t auto attr = feature->attributeLayers()->newLayer("limits")->newAttribute("speed"); attr->validity()->newDirection(Validity::Direction::Both); - auto relation = tile->newRelation("connectedTo", tile->newFeatureId("Way", {{"wayId", 2}})); + auto relation = tile->newRelation( + "connectedTo", + tile->newFeatureId("Way", {{"wayId", 2}}, "ValidationMap")); relation->sourceValidity()->newDirection(Validity::Direction::Positive); relation->targetValidity()->newDirection(Validity::Direction::Negative); feature->addRelation(relation); @@ -778,7 +833,14 @@ TEST_CASE("Single-entry validity collections are exposed as singular nodes", "[t auto materializedRelation = tile->resolve(relation->addr()); REQUIRE(materializedRelation); + REQUIRE(materializedRelation->target()->mapId() == "ValidationMap"); auto const& relationNode = static_cast(*materializedRelation); + auto const targetNode = relationNode.get(StringPool::TargetStr); + REQUIRE(targetNode); + REQUIRE(targetNode->toJson() == nlohmann::json{ + {"id", "Way.2"}, + {"mapId", "ValidationMap"}, + }); auto const sourceValidityNode = relationNode.get(StringPool::SourceValidityStr); REQUIRE(sourceValidityNode); REQUIRE(sourceValidityNode->toJson() == nlohmann::json{{"direction", "POSITIVE"}}); @@ -788,6 +850,104 @@ TEST_CASE("Single-entry validity collections are exposed as singular nodes", "[t REQUIRE(targetValidityNode->toJson() == nlohmann::json{{"direction", "NEGATIVE"}}); } +TEST_CASE("Feature-id validities expose external map references", "[test.featurelayer.validity]") +{ + auto layerInfo = LayerInfo::fromJson(R"({ + "layerId": "WayLayer", + "type": "Features", + "featureTypes": [ + { + "name": "Way", + "uniqueIdCompositions": [[ + {"partId": "wayId", "description": "way id", "datatype": "U32"} + ]] + } + ] + })"_json); + + auto strings = std::make_shared("FeatureRefValidityNode"); + auto tile = std::make_shared( + TileId::fromWgs84(42., 11., 13), + "FeatureRefValidityNode", + "Tropico", + layerInfo, + strings); + auto feature = tile->newFeature("Way", {{"wayId", 1}}); + auto attr = feature->attributeLayers()->newLayer("limits")->newAttribute("speed"); + + auto externalReference = tile->newFeatureId("Way", {{"wayId", 2}}, "ValidationMap"); + attr->validity()->newFeatureId(externalReference, Validity::Direction::Positive); + + auto materializedAttr = tile->resolve(attr->addr()); + REQUIRE(materializedAttr); + auto const& attrNode = static_cast(*materializedAttr); + auto const validityNode = attrNode.get(StringPool::ValidityStr); + REQUIRE(validityNode); + REQUIRE(validityNode->toJson() == nlohmann::json{ + {"direction", "POSITIVE"}, + {"featureId", { + {"id", "Way.2"}, + {"mapId", "ValidationMap"}, + }}, + }); +} + +TEST_CASE("Simple validities upgrade only their owning collection slot", "[test.featurelayer.validity]") +{ + auto layerInfo = LayerInfo::fromJson(R"({ + "layerId": "WayLayer", + "type": "Features", + "featureTypes": [ + { + "name": "Way", + "uniqueIdCompositions": [[ + {"partId": "wayId", "description": "way id", "datatype": "U32"} + ]] + } + ], + "stages": 3, + "stageLabels": ["Low-Fi", "High-Fi", "ADAS"], + "highFidelityStage": 1 + })"_json); + + auto strings = std::make_shared("SimpleValidityUpgradeIsolation"); + auto tile = std::make_shared( + TileId::fromWgs84(42., 11., 13), + "SimpleValidityUpgradeIsolation", + "Tropico", + layerInfo, + strings); + auto feature = tile->newFeature("Way", {{"wayId", 1}}); + + auto attr = feature->attributeLayers()->newLayer("limits")->newAttribute("speed"); + auto attrValidity = attr->validity()->newDirection(Validity::Direction::Positive); + + auto relation = tile->newRelation( + "connectedTo", + tile->newFeatureId("Way", {{"wayId", 2}}, "ValidationMap")); + auto relationValidity = relation->targetValidity()->newDirection(Validity::Direction::Positive); + relationValidity->setGeometryStage(2U); + feature->addRelation(relation); + + auto materializedAttr = tile->resolve(attr->addr()); + REQUIRE(materializedAttr); + auto const& attrNode = static_cast(*materializedAttr); + auto attrValidityNode = attrNode.get(StringPool::ValidityStr); + REQUIRE(attrValidityNode); + + auto materializedRelation = tile->resolve(relation->addr()); + REQUIRE(materializedRelation); + auto const& relationNode = static_cast(*materializedRelation); + auto targetValidityNode = relationNode.get(StringPool::TargetValidityStr); + REQUIRE(targetValidityNode); + + REQUIRE(attrValidityNode->toJson() == nlohmann::json{{"direction", "POSITIVE"}}); + REQUIRE(targetValidityNode->toJson() == nlohmann::json{ + {"direction", "POSITIVE"}, + {"geometryName", "ADAS"}, + }); +} + TEST_CASE("Semantic feature transition validities expose semantic nodes", "[test.featurelayer.validity]") { auto layerInfo = LayerInfo::fromJson(R"({