Repository: ndsev/mapget (language: python)

Issue:
Basic Model Library
To facilitate integration of mapget in various upstream and downstream projects, and initial subset of the `mapget::model` library must be implemented. This should include all classes in the following UML, except for the `TileLayerStream` class.


![Image](https://user-images.githubusercontent.com/8723581/236814825-aaa89305-38c5-45ca-b067-957d0f021bee.png)




Discussion / hints:

Fix patch:
diff --git a/.clang-format b/.clang-format
new file mode 100644
index 00000000..9ac00f0c
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1,57 @@
+# Documentation:
+# https://releases.llvm.org/14.0.0/tools/clang/docs/ClangFormatStyleOptions.html
+# CLion setup guide:
+# https://www.jetbrains.com/help/clion/clangformat-as-alternative-formatter.html#clion-support
+---
+BasedOnStyle: Chromium
+IndentWidth: 4
+---
+Language: Cpp
+ColumnLimit: 100
+
+# Always break after an open bracket, if the parameters don’t fit on a single line.
+AlignAfterOpenBracket: AlwaysBreak
+
+# Function call’s arguments will either be all on the same line or will have one line each.
+BinPackArguments: false
+BinPackParameters: false
+AllowAllArgumentsOnNextLine: false
+
+BreakBeforeBraces: Custom
+BraceWrapping:
+  AfterClass: true
+  AfterControlStatement: MultiLine
+  AfterEnum: false
+  AfterFunction: true
+  AfterNamespace: true
+  AfterObjCDeclaration: true
+  AfterStruct: true
+  AfterUnion: true
+  BeforeCatch: true
+  BeforeElse: true
+  IndentBraces: false
+  BeforeLambdaBody: true
+
+# while (true) { continue; }
+AllowShortBlocksOnASingleLine: Always
+AllowShortLambdasOnASingleLine: Inline
+
+# case 1: return 2;
+AllowShortCaseLabelsOnASingleLine: true
+IndentCaseBlocks: false
+IndentCaseLabels: false
+
+AlignOperands: false
+ContinuationIndentWidth: 4
+
+BreakBeforeBinaryOperators: None
+BreakBeforeTernaryOperators: false
+
+IndentAccessModifiers: false
+AccessModifierOffset: -4
+
+PenaltyIndentedWhitespace: 10
+PenaltyBreakAssignment: 100
+PenaltyReturnTypeOnItsOwnLine: 100
+PenaltyBreakBeforeFirstCallParameter: 0
+PenaltyBreakOpenParenthesis: 200
diff --git a/.clang-tidy b/.clang-tidy
new file mode 100644
index 00000000..0a1f23a5
--- /dev/null
+++ b/.clang-tidy
@@ -0,0 +1,1 @@
+Checks: '-misc-no-recursion'
\ No newline at end of file
diff --git a/.github/workflows/cmake.yaml b/.github/workflows/cmake.yaml
new file mode 100644
index 00000000..0dea66c1
--- /dev/null
+++ b/.github/workflows/cmake.yaml
@@ -0,0 +1,146 @@
+name: Build
+
+on:
+  push:
+    branches: [ master, main ]
+  pull_request:
+    branches: [ '**' ]
+
+jobs:
+  build-linux:
+    name: Linux
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: true
+      - name: Install dependencies
+        run: |
+          sudo apt-get install gcovr ninja-build valgrind
+      - name: Setup
+        run: |
+          mkdir build
+      - name: Configure
+        working-directory: build
+        run: |
+          cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_EXPORT_COMPILE_COMMANDS=YES -GNinja ..
+      - name: Build
+        working-directory: build
+        run: |
+          cmake --build .
+
+      - name: Run CTest
+        working-directory: build
+        run: |
+          ctest --verbose --output-junit result-junit.xml
+      # - name: Publish Test Results
+      #   uses: EnricoMi/publish-unit-test-result-action@v2
+      #   id: test-results
+      #   if: always()
+      #   with:
+      #     files: |
+      #       build/test/*.xml
+      # - name: Set badge color
+      #   shell: bash
+      #   run: |
+      #     case ${{ fromJSON( steps.test-results.outputs.json ).conclusion }} in
+      #       success)
+      #         echo "BADGE_COLOR=31c653" >> $GITHUB_ENV
+      #         ;;
+      #       failure)
+      #         echo "BADGE_COLOR=800000" >> $GITHUB_ENV
+      #         ;;
+      #       neutral)
+      #         echo "BADGE_COLOR=696969" >> $GITHUB_ENV
+      #         ;;
+      #     esac
+      # - name: Create badge
+      #   uses: emibcn/badge-action@d6f51ff11b5c3382b3b88689ae2d6db22d9737d1
+      #   with:
+      #     label: Tests
+      #     status: '${{ fromJSON( steps.test-results.outputs.json ).formatted.stats.tests }} tests, ${{ fromJSON( steps.test-results.outputs.json ).formatted.stats.runs }} runs: ${{ fromJSON( steps.test-results.outputs.json ).conclusion }}'
+      #     color: ${{ env.BADGE_COLOR }}
+      #     path: badge.svg
+      # - name: Upload badge to Gist
+      #   # Upload only for main branch
+      #   if: >
+      #     github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'main' ||
+      #     github.event_name != 'workflow_run' && github.ref == 'refs/heads/main'
+      #   uses: andymckay/append-gist-action@1fbfbbce708a39bd45846f0955ed5521f2099c6d
+      #   with:
+      #     token: ${{ secrets.GIST_TOKEN }}
+      #     gistURL: https://gist.githubusercontent.com/johannes-wolf/61e57af50757b03e0c7cd119ec2d2f4b
+      #     file: badge.svg
+
+      - name: Run Valgrind Memcheck
+        working-directory: build/bin
+        run: |
+          valgrind --tool=memcheck ./test.mapget
+      - name: Run Valgrind Helgrind
+        working-directory: build/bin
+        run: |
+          valgrind --tool=helgrind ./test.mapget
+      - name: Run Valgrind Cachegrind
+        working-directory: build/bin
+        run: |
+          valgrind --tool=cachegrind ./test.mapget | tee cachegrind-out.txt
+      - name: Run Valgrind Callgrind
+        working-directory: build/bin
+        run: |
+          valgrind --tool=callgrind --instr-atstart=no ./test.mapget
+      - name: Publish Callgrind Output
+        uses: actions/upload-artifact@v3
+        with:
+          name: Callgrind Output
+          path: build/bin/callgrind.out.*
+
+  build-windows:
+    name: Windows
+    runs-on: windows-latest
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: true
+      - name: Setup
+        run: |
+          mkdir build
+      - name: Configure
+        working-directory: build
+        run: |
+          cmake ..
+        shell: cmd
+      - name: Build
+        working-directory: build
+        run: |
+          cmake --build .
+        shell: cmd
+      - name: Run CTest
+        working-directory: build/test
+        run: |
+          ctest -C Debug --verbose
+        shell: cmd
+
+  build-macos:
+    name: MacOS
+    runs-on: macos-latest
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: true
+      - name: Setup
+        run: |
+          brew install openssl        
+          mkdir build
+      - name: Configure
+        working-directory: build
+        run: |
+          export OPENSSL_ROOT_DIR=$(brew --prefix openssl)
+          cmake ..
+      - name: Build
+        working-directory: build
+        run: |
+          cmake --build .
+      - name: Run CTest
+        working-directory: build/test
+        run: |
+          ctest --verbose
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
new file mode 100644
index 00000000..a0d5b060
--- /dev/null
+++ b/.github/workflows/coverage.yml
@@ -0,0 +1,83 @@
+name: Coverage
+
+on:
+  push:
+    branches: [ master, main ]
+  pull_request:
+    branches: [ '**' ]
+
+jobs:
+  coverage-linux:
+    name: Coverage
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v3
+      with:
+        submodules: true
+    - name: Install dependencies
+      run: |
+        sudo apt-get install ninja-build
+        pip install gcovr
+        gcovr --version
+    - name: Setup
+      run: |
+        mkdir build
+    - name: Configure
+      working-directory: build
+      run: |
+        cmake -DCMAKE_BUILD_TYPE=Debug \
+              -DCMAKE_EXPORT_COMPILE_COMMANDS=YES \
+              -DWITH_COVERAGE=YES -GNinja ..
+    - name: Build
+      working-directory: build
+      run: |
+        cmake --build .
+    - name: Run Test
+      working-directory: build/
+      run: |
+        ctest
+    - name: Run Gcovr
+      run: |
+        mkdir coverage
+        gcovr --html-details coverage/coverage.html \
+              --filter libs/
+        gcovr --cobertura coverage.xml \
+              --html coverage.html \
+              --filter libs/
+
+    - name: Publish Coverage HTML
+      uses: actions/upload-artifact@v3
+      with:
+        name: Test Coverage
+        path: coverage
+
+    # - name: Upload coverage HTML to Gist
+    #   # Upload only for main branch
+    #   if: >
+    #     github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'main' ||
+    #     github.event_name != 'workflow_run' && github.ref == 'refs/heads/main'
+    #   uses: andymckay/append-gist-action@1fbfbbce708a39bd45846f0955ed5521f2099c6d
+    #   with:
+    #     token: ${{ secrets.GIST_TOKEN }}
+    #     gistURL: https://gist.githubusercontent.com/johannes-wolf/61e57af50757b03e0c7cd119ec2d2f4b
+    #     file: coverage.html
+
+    - name: Code Coverage Report
+      uses: irongut/CodeCoverageSummary@v1.3.0
+      with:
+        filename: coverage.xml
+        badge: false
+        fail_below_min: true
+        format: markdown
+        hide_branch_rate: false
+        hide_complexity: true
+        indicators: true
+        output: both
+        thresholds: '50 80'
+
+    - name: Add Coverage PR Comment
+      uses: marocchino/sticky-pull-request-comment@v2
+      if: github.event_name == 'pull_request'
+      with:
+        recreate: true
+        path: code-coverage-results.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..bdda9def
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+cmake-build-debug/
+cmake-build-release/
+.clang-format
+.idea/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..e06aafa4
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "cmake/cmake-modules"]
+	path = cmake/cmake-modules
+	url = https://github.com/bilke/cmake-modules
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 00000000..1d2ca63d
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,130 @@
+cmake_minimum_required(VERSION 3.14)
+
+if (NOT PROJECT_NAME)
+  set (MAPGET_ENABLE_TESTING ON)
+endif()
+
+project(mapget)
+include(FetchContent)
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(MAPGET_VERSION 0.0.1)
+
+option(MAPGET_BUILD_WHEEL "Enable mapget whl-output to WHEEL_DEPLOY_DIRECTORY." ON)
+
+if (NOT MSVC)
+  set(CMAKE_POSITION_INDEPENDENT_CODE ON)
+  set(CMAKE_CXX_FLAGS -fPIC)
+endif()
+
+if (NOT MAPGET_DEPLOY_DIR)
+  set (MAPGET_DEPLOY_DIR "${CMAKE_BINARY_DIR}/bin")
+endif()
+set (CMAKE_LIBRARY_OUTPUT_DIRECTORY "${MAPGET_DEPLOY_DIR}")
+set (CMAKE_RUNTIME_OUTPUT_DIRECTORY "${MAPGET_DEPLOY_DIR}")
+
+if (CMAKE_COMPILER_IS_GNUCXX AND CMAKE_BUILD_TYPE STREQUAL "Debug")
+  option(WITH_COVERAGE "Enable gcovr coverage" YES)
+else()
+  set(WITH_COVERAGE NO)
+endif()
+
+find_program(GCOVR_BIN gcovr)
+if (WITH_COVERAGE AND NOT GCOVR_BIN)
+  set(WITH_COVERAGE NO)
+  message(WARNING "Could not find gcovr binary. Disabling coverage report!")
+endif()
+
+list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/cmake-modules")
+
+##############
+# deps
+
+find_package(OpenSSL REQUIRED)
+
+# if (MAPGET_BUILD_WHEEL)
+#   if (NOT TARGET wheel)
+#     set(Python3_FIND_STRATEGY LOCATION)
+#     set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/deps/python-cmake-wheel")
+#     include(python-wheel)
+#     set(WHEEL_DEPLOY_DIRECTORY "${MAPGET_DEPLOY_DIR}/wheel")
+#   endif()
+# endif()
+
+if (NOT TARGET stx)
+  FetchContent_Declare(stx
+    GIT_REPOSITORY "https://github.com/Klebert-Engineering/stx.git"
+    GIT_TAG        "main"
+    GIT_SHALLOW    ON)
+  FetchContent_MakeAvailable(stx)
+endif()
+
+if (NOT TARGET spdlog)
+  FetchContent_Declare(spdlog
+    GIT_REPOSITORY "https://github.com/gabime/spdlog.git"
+    GIT_TAG        "v1.x"
+    GIT_SHALLOW    ON)
+  FetchContent_MakeAvailable(spdlog)
+endif()
+
+set(SIMFIL_WITH_MODEL_JSON YES)
+if (NOT TARGET simfil)
+  FetchContent_Declare(simfil
+    GIT_REPOSITORY "https://github.com/Klebert-Engineering/simfil.git"
+    GIT_TAG        "main"
+    GIT_SHALLOW    ON)
+  FetchContent_MakeAvailable(simfil)
+endif()
+
+if (NOT TARGET httplib)
+  FetchContent_Declare(httplib
+    GIT_REPOSITORY "https://github.com/yhirose/cpp-httplib.git"
+    GIT_TAG        "master"
+    GIT_SHALLOW    ON)
+  FetchContent_MakeAvailable(httplib)
+  target_compile_definitions(httplib
+    INTERFACE
+      CPPHTTPLIB_OPENSSL_SUPPORT)
+  target_link_libraries(httplib INTERFACE OpenSSL::SSL)
+endif()
+
+if (NOT TARGET bitsery)
+  FetchContent_Declare(bitsery
+    GIT_REPOSITORY "https://github.com/fraillt/bitsery.git"
+    GIT_TAG        "master"
+    GIT_SHALLOW    ON)
+  FetchContent_MakeAvailable(bitsery)
+endif()
+
+if (WITH_COVERAGE)
+  include(CodeCoverage)
+endif()
+
+##############
+# libs
+
+add_subdirectory(libs/model)
+
+##############
+# tests
+
+if (MAPGET_ENABLE_TESTING)
+  enable_testing()
+  add_subdirectory(test)
+
+  if (WITH_COVERAGE)
+    setup_target_for_coverage_gcovr_html(
+      NAME coverage
+      EXECUTABLE test.mapget
+      BASE_DIRECTORY "${CMAKE_SOURCE_DIR}/src"
+      EXCLUDE "*catch2*")
+  endif()
+endif()
+
+##############
+# wheels
+
+# if (MAPGET_BUILD_WHEEL)
+#   add_subdirectory(wheel)
+# endif()
diff --git a/README.md b/README.md
index 9528fa25..730e8ec5 100644
--- a/README.md
+++ b/README.md
@@ -263,7 +263,7 @@ the file is as follows:
 
 ```yaml
 sources:
-  - <url>  # e.g. http://localhost:12345
+   - <url>  # e.g. http://localhost:12345
 ```
 
 ## Architecture
diff --git a/cmake/cmake-modules b/cmake/cmake-modules
new file mode 160000
index 00000000..877bab9d
--- /dev/null
+++ b/cmake/cmake-modules
@@ -0,0 +1,1 @@
+Subproject commit 877bab9dd1b17468c5d939cacaa2ad7ba99d1977
diff --git a/libs/model/CMakeLists.txt b/libs/model/CMakeLists.txt
new file mode 100644
index 00000000..d588cc4f
--- /dev/null
+++ b/libs/model/CMakeLists.txt
@@ -0,0 +1,49 @@
+project(mapget-model)
+
+add_library(mapget-model STATIC
+  include/mapget/model/fields.h
+  include/mapget/model/layer.h
+  include/mapget/model/featurelayer.h
+  include/mapget/model/info.h
+  include/mapget/model/feature.h
+  include/mapget/model/attr.h
+  include/mapget/model/attrlayer.h
+  include/mapget/model/tileid.h
+  include/mapget/model/featureid.h
+
+  src/fields.cpp
+  src/layer.cpp
+  src/featurelayer.cpp
+  src/info.cpp
+  src/feature.cpp
+  src/attr.cpp
+  src/attrlayer.cpp
+  src/tileid.cpp
+  src/featureid.cpp)
+
+target_include_directories(mapget-model
+  PUBLIC
+    include
+  PRIVATE
+    include/mapget/model)
+
+target_link_libraries(mapget-model
+  PUBLIC
+    sfl::sfl
+    stx::stx
+    Bitsery::bitsery
+    simfil::simfil
+    nlohmann_json::nlohmann_json)
+
+if (MSVC)
+  target_compile_definitions(mapget-model
+    PRIVATE
+      _CRT_SECURE_NO_WARNINGS)
+
+  set_target_properties(mapget-model PROPERTIES
+    WINDOWS_EXPORT_ALL_SYMBOLS ON)
+endif()
+
+if (WITH_COVERAGE)
+  append_coverage_compiler_flags_to_target(mapget-model)
+endif()
diff --git a/libs/model/include/mapget/model/attr.h b/libs/model/include/mapget/model/attr.h
new file mode 100644
index 00000000..04d6feca
--- /dev/null
+++ b/libs/model/include/mapget/model/attr.h
@@ -0,0 +1,59 @@
+#pragma once
+
+#include "featureid.h"
+
+namespace mapget
+{
+
+/**
+ * Represents a feature attribute which belongs to an
+ * AttributeLayer, and may have typed `direction` and some
+ * `validity` fields in addition to other arbitrary object fields.
+ */
+class Attribute : public simfil::ProceduralObject<2, Attribute>
+{
+    friend class TileFeatureLayer;
+
+public:
+    /**
+     * Attribute direction values - may be used as flags.
+     */
+    enum Direction : uint8_t {
+        Empty = 0x0,    // No set direction
+        Positive = 0x1, // Positive (digitization) direction
+        Negative = 0x2, // Negative (against digitization) direction
+        Both = 0x3,     // Both positive and negative direction
+        None = 0x4,     // Not in any direction
+    };
+
+    /**
+     * Attribute direction accessors.
+     */
+    [[nodiscard]] Direction direction() const;
+    void setDirection(Direction const& v);
+
+    /**
+     * Attribute validity accessor.
+     */
+    model_ptr<Geometry> validity();
+
+    /**
+     * Read-only attribute name accessor.
+     */
+    std::string_view name();
+
+protected:
+
+    struct Data {
+        Direction direction_ = Empty;
+        simfil::ModelNodeAddress validity_;
+        simfil::ArrayIndex fields_ = -1;
+        simfil::FieldId name_ = 0;
+    };
+
+    Attribute(Data& data, simfil::ModelConstPtr l, simfil::ModelNodeAddress a);
+
+    Data& data_;
+};
+
+}
\ No newline at end of file
diff --git a/libs/model/include/mapget/model/attrlayer.h b/libs/model/include/mapget/model/attrlayer.h
new file mode 100644
index 00000000..df298ea8
--- /dev/null
+++ b/libs/model/include/mapget/model/attrlayer.h
@@ -0,0 +1,65 @@
+#pragma once
+
+#include "featureid.h"
+#include "attr.h"
+
+namespace mapget
+{
+
+class AttributeLayerList;
+
+/**
+ * Represents a collection of Attributes which are semantically related.
+ * For example, all feature attributes which refer to road rules, such
+ * as speed limits, might belong to the same attribute layer.
+ */
+class AttributeLayer : protected simfil::Object
+{
+    friend class TileFeatureLayer;
+    friend class bitsery::Access;
+    friend struct simfil::shared_model_ptr<simfil::ModelNode>;
+
+public:
+    /**
+     * Create a new attribute and immediately insert it into the layer.
+     */
+    model_ptr<Attribute> newAttribute(std::string_view const& name, size_t initialCapacity=8);
+
+    /**
+     * Add an attribute to the layer which was created before - note:
+     * the same attribute can be added to multiple layers for different
+     * features, it will not be copied.
+     */
+    void addAttribute(model_ptr<Attribute> a);
+
+protected:
+    AttributeLayer(simfil::ArrayIndex i, simfil::ModelConstPtr l, simfil::ModelNodeAddress a);
+};
+
+/**
+ * Collection of attribute layers - this is merely a typed dict which
+ * stores (layer-name, layer) pairs.
+ */
+class AttributeLayerList : protected simfil::Object
+{
+    friend class TileFeatureLayer;
+    friend class bitsery::Access;
+    friend class Feature;
+
+public:
+    /**
+     * Create a new named layer and immediately insert it into the collection.
+     */
+    model_ptr<AttributeLayer> newLayer(std::string_view const& name, size_t initialCapacity=8);
+
+    /**
+     * Add an attribute layer to the collection which was previously created.
+     * You can share a single layer between multiple collections, it will not be copied.
+     */
+    void addLayer(std::string_view const& name, model_ptr<AttributeLayer> l);
+
+protected:
+    AttributeLayerList(simfil::ArrayIndex i, simfil::ModelConstPtr l, simfil::ModelNodeAddress a);
+};
+
+}
\ No newline at end of file
diff --git a/libs/model/include/mapget/model/feature.h b/libs/model/include/mapget/model/feature.h
new file mode 100644
index 00000000..ea533761
--- /dev/null
+++ b/libs/model/include/mapget/model/feature.h
@@ -0,0 +1,117 @@
+#pragma once
+
+#include "featureid.h"
+#include "attrlayer.h"
+#include "attr.h"
+
+#include "nlohmann/json.hpp"
+
+#include "sfl/small_vector.hpp"
+
+namespace mapget
+{
+
+/**
+ * View onto a feature which belongs to a TileFeatureLayer.
+ * You can create a feature through the TileFeatureLayer::newFeature function.
+ * A Feature object maps to a GeoJSON feature object in the following way:
+ *
+ *   {
+ *     type: "Feature",  # Mandatory for GeoJSON compliance
+ *     id: "<type-id>.<part-value-0>...<part-value-n>",
+ *     typeId: "<type-id>",
+ *     <part-name-n>: <part-value-n>, ...
+ *     geometry: <geojson-geometry>,
+ *     properties: {
+ *       layers: {
+ *         <attr-layer-name>: {
+ *           <attr-name>: {
+ *             <attr-fields> ...,
+ *             direction: <attr-direction>,
+ *             validity: <attr-validity-geometry>
+ *           }
+ *         }, ...
+ *       },
+ *       <non-layer-attr-name>: <non-layer-attr-value>, ...
+ *     },
+ *     children: [<child-id-list>]
+ *   }
+ */
+class Feature : protected simfil::MandatoryDerivedModelNodeBase<TileFeatureLayer>
+{
+    friend class bitsery::Access;
+    friend class TileFeatureLayer;
+
+public:
+    [[nodiscard]] std::string_view typeId() const;
+
+    [[nodiscard]] model_ptr<FeatureId> id() const;
+    [[nodiscard]] model_ptr<GeometryCollection> geom();
+    [[nodiscard]] model_ptr<AttributeLayerList> attributeLayers();
+    [[nodiscard]] model_ptr<Object> attributes();
+    [[nodiscard]] model_ptr<Array> children();
+
+    /**
+     * Evaluate a filter expression on this feature.
+     */
+    std::vector<simfil::Value> evaluateAll(std::string_view const& expression);
+    simfil::Value evaluate(std::string_view const& expression);
+
+    /**
+     * Convert the Feature to GeoJSON.
+     */
+    nlohmann::json toGeoJson();
+
+
+protected:
+    /**
+     * Simfil Model-Node Functions
+     */
+    [[nodiscard]] simfil::ValueType type() const override;
+    [[nodiscard]] ModelNode::Ptr at(int64_t) const override;
+    [[nodiscard]] uint32_t size() const override;
+    [[nodiscard]] ModelNode::Ptr get(const simfil::FieldId &) const override;
+    [[nodiscard]] simfil::FieldId keyAt(int64_t) const override;
+    [[nodiscard]] bool iterate(IterCallback const& cb) const override;
+
+    /**
+     * Feature Data
+     */
+    struct Data
+    {
+        simfil::ModelNodeAddress id_;
+        simfil::ModelNodeAddress geom_;
+        simfil::ModelNodeAddress attrLayers_;
+        simfil::ModelNodeAddress attrs_;
+        simfil::ModelNodeAddress children_;
+    };
+
+    Feature(Data& d, simfil::ModelConstPtr l, simfil::ModelNodeAddress a);
+
+    Data& data_;
+
+    // We keep the fields in a tiny vector on the stack,
+    // because their number is dynamic, as a variable number
+    // of id-part fields is adopted from the feature id.
+    sfl::small_vector<std::pair<simfil::FieldId, simfil::ModelNode::Ptr>, 32> fields_;
+    void updateFields();
+
+    nlohmann::json toJsonPrivate(simfil::ModelNode const&);
+
+    struct FeaturePropertyView : public simfil::MandatoryDerivedModelNodeBase<TileFeatureLayer>
+    {
+        [[nodiscard]] simfil::ValueType type() const override;
+        [[nodiscard]] ModelNode::Ptr at(int64_t) const override;
+        [[nodiscard]] uint32_t size() const override;
+        [[nodiscard]] ModelNode::Ptr get(const simfil::FieldId &) const override;
+        [[nodiscard]] simfil::FieldId keyAt(int64_t) const override;
+        [[nodiscard]] bool iterate(IterCallback const& cb) const override;
+
+        FeaturePropertyView(Data& d, simfil::ModelConstPtr l, simfil::ModelNodeAddress a);
+
+        Data& data_;
+        std::optional<model_ptr<Object>> attrs_;
+    };
+};
+
+}
\ No newline at end of file
diff --git a/libs/model/include/mapget/model/featureid.h b/libs/model/include/mapget/model/featureid.h
new file mode 100644
index 00000000..b150245c
--- /dev/null
+++ b/libs/model/include/mapget/model/featureid.h
@@ -0,0 +1,64 @@
+#pragma once
+
+#include "simfil/model/nodes.h"
+#include <functional>
+
+namespace mapget
+{
+
+class TileFeatureLayer;
+using FeatureLayerConstPtr = std::shared_ptr<TileFeatureLayer const>;
+
+template<typename T>
+using model_ptr = simfil::shared_model_ptr<T>;
+
+using Object = simfil::Object;
+using Array = simfil::Array;
+using GeometryCollection = simfil::GeometryCollection;
+using Geometry = simfil::Geometry;
+using GeomType = simfil::Geometry::GeomType;
+
+/**
+ * Unique feature ID
+ */
+class FeatureId : protected simfil::MandatoryDerivedModelNodeBase<TileFeatureLayer>
+{
+    friend class TileFeatureLayer;
+    friend class Feature;
+    friend class bitsery::Access;
+
+public:
+    /** Convert the FeatureId to a string like `<type-id>.<part-value-0>...<part-value-n>` */
+    [[nodiscard]] std::string toString() const;
+
+    /** Get the feature ID's type id. */
+    [[nodiscard]] std::string_view typeId() const;
+
+protected:
+    /**
+     * Internal Node Access APIs
+     */
+    [[nodiscard]] simfil::ValueType type() const override;
+    [[nodiscard]] simfil::ScalarValueType value() const override;
+    [[nodiscard]] ModelNode::Ptr at(int64_t) const override;
+    [[nodiscard]] uint32_t size() const override;
+    [[nodiscard]] ModelNode::Ptr get(const simfil::FieldId &) const override;
+    [[nodiscard]] simfil::FieldId keyAt(int64_t) const override;
+    [[nodiscard]] bool iterate(IterCallback const& cb) const override;
+
+    struct Data {
+        bool useCommonTilePrefix_ = false;
+        simfil::FieldId typeId_ = 0;
+        simfil::ModelNodeAddress idParts_;
+    };
+
+    FeatureId(Data& data, simfil::ModelConstPtr l, simfil::ModelNodeAddress a);
+
+    Data& data_;
+
+    // Optional because resolve must be called, in
+    // the constructor body.
+    model_ptr<Object> fields_;
+};
+
+}
\ No newline at end of file
diff --git a/libs/model/include/mapget/model/featurelayer.h b/libs/model/include/mapget/model/featurelayer.h
new file mode 100644
index 00000000..8818cbbb
--- /dev/null
+++ b/libs/model/include/mapget/model/featurelayer.h
@@ -0,0 +1,192 @@
+#pragma once
+
+#include "simfil/simfil.h"
+#include "simfil/model/model.h"
+#include "simfil/model/arena.h"
+#include "simfil/environment.h"
+
+#include "fields.h"
+#include "layer.h"
+#include "feature.h"
+#include "attrlayer.h"
+
+namespace mapget
+{
+
+/**
+ * Forward declaration for the Feature class
+ */
+class Feature;
+
+/**
+ * The KeyValuePairs type is a vector of pairs, where each pair
+ * consists of a string_view key and a variant value that can be
+ * either an int64_t or a string_view.
+ */
+using KeyValuePairs = std::vector<std::pair<
+    std::string_view,
+    std::variant<int64_t, std::string_view>>>;
+
+/**
+ * The TileFeatureLayer class represents a specific map layer
+ * within a map tile. It is a container for map features.
+ * You can iterate over all contained features using `for (auto&& feature : tileFeatureLayer)`.
+ */
+class TileFeatureLayer : public TileLayer, public simfil::ModelPool
+{
+    friend class Feature;
+    friend class FeatureId;
+
+public:
+    /**
+     * This constructor initializes a new TileFeatureLayer instance.
+     * Each instance is associated with a specific TileId, nodeId, and mapId.
+     * @param tileId The tile id of the new feature layer. Features in this layer
+     *  should be roughly within the area indicated by the tile.
+     * @param nodeId Unique id of the data source node which produced this feature.
+     * @param mapId ID of the map which the layer belongs to.
+     * @param layerInfo Information about the map layer this feature is associated with.
+     *  Each feature in this layer must have a feature type which is also present in
+     *  the layer. Therefore, feature ids from this layer can be verified to conform
+     *  to one of the allowed feature id compositions for the allowed type.
+     * @param fields Shared field name dictionary, which allows compressed storage
+     *  of object field name strings. It is auto-filled, and one instance may be used
+     *  by multiple TileFeatureLayer instances.
+     */
+    TileFeatureLayer(
+        TileId tileId,
+        std::string const& nodeId,
+        std::string const& mapId,
+        std::shared_ptr<LayerInfo> const& layerInfo,
+        std::shared_ptr<Fields> const& fields
+    );
+
+    /**
+     * Set common id prefix for all features in this layer.
+     */
+    void setPrefix(KeyValuePairs const& prefix);
+
+    /** Destructor for the TileFeatureLayer class. */
+    ~TileFeatureLayer();
+
+    /**
+     * Creates a new feature and insert it into this tile layer.
+     * The unique identifying information, prepended with the featureIdPrefix,
+     * must conform to an existing UniqueIdComposition for the feature typeId
+     * within the associated layer.
+     * @param typeId Specifies the type of the feature.
+     * @param featureIdParts Uniquely identifying information for the feature,
+     * according to the requirements of typeId.
+     */
+    model_ptr<Feature> newFeature(
+        std::string_view const& typeId,
+        KeyValuePairs const& featureIdParts);
+
+    /**
+     * 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.
+     */
+    model_ptr<FeatureId> newFeatureId(
+        std::string_view const& typeId,
+        KeyValuePairs const& featureIdParts);
+
+    /**
+     * Create a new named attribute, which may be inserted into an attribute layer.
+     */
+    model_ptr<Attribute> newAttribute(std::string_view const& name, size_t initialCapacity=8);
+
+    /**
+     * Create a new attribute layer, which may be inserted into a feature.
+     */
+    model_ptr<AttributeLayer> newAttributeLayer(size_t initialCapacity=8);
+
+    /**
+     * Return type for begin() and end() methods to support range-based
+     * for-loops to iterate over all features in a TileFeatureLayer.
+     */
+    struct Iterator
+    {
+        Iterator(TileFeatureLayer const& layer, size_t i) : layer_(layer), i_(i) {}
+        model_ptr<Feature> operator*() { return layer_.resolveFeature(*layer_.root(i_)); }
+        Iterator& operator++()
+        {
+            ++i_;
+            return *this;
+        }
+        bool operator==(const Iterator& other) const
+        {
+            return &layer_ == &other.layer_ && i_ == other.i_;
+        }
+        bool operator!=(const Iterator& other) const { return !(*this == other); }
+        using iterator_category = std::input_iterator_tag;
+        using value_type = model_ptr<Feature>;
+        using difference_type = std::ptrdiff_t;
+        using pointer = value_type*;
+        using reference = value_type&;
+    private:
+        TileFeatureLayer const& layer_;
+        size_t i_ = 0;
+    };
+
+    /**
+     * begin()/end() support range-based for-loops to iterate over all
+     * features in a TileFeatureLayer.
+     */
+    Iterator begin() const;
+    Iterator end() const;
+
+protected:
+    /**
+     * The FeatureTileColumnId enum provides identifiers for different
+     * types of columns that can be associated with feature data.
+     */
+    enum FeatureTileColumnId : uint8_t {
+        Features = FirstCustomColumnId,
+        FeatureProperties,
+        FeatureIds,
+        Attributes,
+        AttributeLayers,
+        AttributeLayerLists,
+        Icons
+    };
+
+    /**
+     * The featureIdPrefix function returns common ID parts,
+     * which are shared by all features in this layer.
+     */
+    std::optional<model_ptr<Object>> featureIdPrefix();
+
+    /**
+     * Create a new attribute layer collection.
+     */
+    model_ptr<AttributeLayerList> newAttributeLayers(size_t initialCapacity=8);
+
+    /**
+     * Node resolution functions.
+     */
+    model_ptr<AttributeLayer> resolveAttributeLayer(simfil::ModelNode const& n) const;
+    model_ptr<AttributeLayerList> resolveAttributeLayerList(simfil::ModelNode const& n) const;
+    model_ptr<Attribute> resolveAttribute(simfil::ModelNode const& n) const;
+    model_ptr<Feature> resolveFeature(simfil::ModelNode const& n) const;
+    model_ptr<FeatureId> resolveFeatureId(simfil::ModelNode const& n) const;
+
+    /**
+     * Generic node resolution overload
+     */
+    void resolve(const simfil::ModelNode &n, const ResolveFn &cb) const override;
+
+    /**
+     * Get this pool's simfil evaluation environment.
+     */
+    simfil::Environment& evaluationEnvironment();
+
+    /**
+     * Get a potentially cached compiled simfil expression for a simfil string.
+     */
+    simfil::ExprPtr const& compiledExpression(std::string_view const& expr);
+
+    struct Impl;
+    std::unique_ptr<Impl> impl_;
+};
+}
diff --git a/libs/model/include/mapget/model/fields.h b/libs/model/include/mapget/model/fields.h
new file mode 100644
index 00000000..b0ae1030
--- /dev/null
+++ b/libs/model/include/mapget/model/fields.h
@@ -0,0 +1,36 @@
+#pragma once
+
+#include "simfil/model/fields.h"
+
+namespace mapget
+{
+
+/**
+ * The Fields class is a case-insensitive dictionary for field names.
+ * It acts as a container that automatically populates itself when fields are added to an object.
+ * Multiple TileFeatureLayers can share the same Fields dictionary, which helps reduce the size
+ * of serialized FeatureLayers.
+ *
+ * The inherited mapget::Fields provides additional static field IDs that can be used for various purposes.
+ *
+ * Field IDs managed by the Fields dictionary are stored as uint16 values, which are smaller and faster
+ * to work with compared to strings. When querying fields, the Fields dictionary is used to look up the
+ * field ID for a given name. Once the ID is obtained, subsequent searches are performed using this 16-bit
+ * integer, resulting in improved efficiency.
+ */
+struct Fields : public simfil::Fields
+{
+    enum StaticFieldIds : simfil::FieldId {
+        IdStr = NextStaticId,
+        TypeIdStr,
+        LayerStr,
+        ChildrenStr,
+        DirectionStr,
+        ValidityStr,
+        PropertiesStr,
+    };
+
+    Fields();
+};
+
+}
diff --git a/libs/model/include/mapget/model/info.h b/libs/model/include/mapget/model/info.h
new file mode 100644
index 00000000..03f1d11f
--- /dev/null
+++ b/libs/model/include/mapget/model/info.h
@@ -0,0 +1,122 @@
+#pragma once
+
+#include <string>
+#include <vector>
+#include <memory>
+#include <nlohmann/json.hpp>
+
+#include "tileid.h"
+
+namespace mapget
+{
+/** Enum to represent the possible data types */
+enum class IdPartDataType {I32, U32, I64, U64, UUID128, STR};
+
+/** Enum to represent the possible layer types */
+enum class LayerType {Features, Heightmap, OrthoImage, GLTF};
+
+/** Structure to represent a part of a unique feature id composition. */
+struct UniqueIdPart
+{
+    /** Unique identifier part */
+    std::string partId;
+
+    /** Description of the unique identifier */
+    std::string description;
+
+    /** Datatype of the unique identifier */
+    IdPartDataType datatype;
+
+    /** Is the identifier synthetic or part of a map specification? */
+    bool isSynthetic = false;
+
+    /** Is the identifier actually optional? */
+    bool isOptional = false;
+};
+
+/** Structure to represent the feature type info */
+struct FeatureTypeInfo {
+    /** Name of the feature type */
+    std::string name;
+
+    /**
+     * List of allowed unique id compositions (each id composition is a list of id parts)
+     * A single id composition must never have more than 16 parts.
+     */
+    std::vector<std::vector<UniqueIdPart>> uniqueIdCompositions;
+};
+
+/**
+ * Structure to represent a list of coverage flags
+ * as a rectangle between a minimum and a maximum tile id.
+ */
+struct Coverage {
+    /** Minimum tile id */
+    TileId min;
+
+    /** Maximum tile id. Must have same zoom level as min. */
+    TileId max;
+
+    /**
+     * Bitset indicating where the associated layer is filled.
+     * Must have size (max.x() - min.x() + 1) * (max.y() - min.y() + 1).
+     * Bits are stored row-major: [ y0x0..., y0xn, y1x0..., y1xn, ... ]
+     */
+    std::vector<bool> filled;
+};
+
+/**
+ * Structure to represent the layer info
+ */
+struct LayerInfo
+{
+    /** Unique identifier of the layer. */
+    std::string layerId;
+
+    /** Type of the layer */
+    LayerType type = LayerType::Features;
+
+    /** List of feature types */
+    std::vector<FeatureTypeInfo> featureTypes;
+
+    /** List of zoom levels */
+    std::vector<int> zoomLevels;
+
+    /**
+     * List of Coverage structures.
+     * Multiple coverages may exist for the same map layer.
+     */
+    std::vector<Coverage> coverage;
+
+    /** Can this layer be read from? */
+    bool canRead = true;
+
+    /** Can this layer be written to? */
+    bool canWrite = false;
+};
+
+/**
+ * Structure to represent the data source info
+ */
+struct DataSourceInfo
+{
+    /** Unique identifier of the node */
+    std::string nodeId;
+
+    /** Unique identifier of the map */
+    std::string mapId;
+
+    /** List of layers */
+    std::map<std::string, std::shared_ptr<LayerInfo>> layers;
+
+    /** Maximum number of parallel jobs */
+    int maxParallelJobs = 8;
+
+    /** Version of the protocol */
+    int protocolVersion = 0;
+
+    /** Extra JSON attachment. May also be used to store style-sheets. */
+    nlohmann::json extraJsonAttachment;
+};
+
+}  // End of namespace mapget
\ No newline at end of file
diff --git a/libs/model/include/mapget/model/layer.h b/libs/model/include/mapget/model/layer.h
new file mode 100644
index 00000000..9466db9e
--- /dev/null
+++ b/libs/model/include/mapget/model/layer.h
@@ -0,0 +1,109 @@
+#pragma once
+
+#include "info.h"
+#include "tileid.h"
+
+#include "nlohmann/json.hpp"
+
+#include <string>
+#include <chrono>
+#include <optional>
+#include <memory>
+
+namespace mapget
+{
+
+/**
+ * Tile Layer base class. Used by TileFeatureLayer class and other
+ * tile-specific data containers.
+ */
+class TileLayer
+{
+public:
+    /**
+     * Constructor that takes tileId_, nodeId_, mapId_, layerInfo_,
+     * and sets the timestamp_ to the current system time.
+     */
+    TileLayer(
+        const TileId& id,
+        std::string node_id,
+        std::string map_id,
+        const std::shared_ptr<LayerInfo>& info);
+
+    /**
+     * Getter and setter for layer's tileId. This controls the rough
+     * geographic extent of the contained tile data.
+     */
+    [[nodiscard]] TileId tileId() const;
+    void setTileId(const TileId& id);
+
+    /**
+     * Getter and setter for layer's nodeId. This is the identifier of
+     * the data source process which created this layer.
+     */
+    [[nodiscard]] std::string nodeId() const;
+    void setNodeId(const std::string& id);
+
+    /**
+     * Getter and setter for the layer's mapId. This is the identifier
+     * of the map which is this tile layer belongs to.
+     */
+    [[nodiscard]] std::string mapId() const;
+    void setMapId(const std::string& id);
+
+    /**
+     * Getter and setter for 'layerInfo_' member variable.
+     * It holds LayerInfo reference for this TileLayer.
+     */
+    [[nodiscard]] std::shared_ptr<LayerInfo> layerInfo() const;
+    void setLayerInfo(const std::shared_ptr<LayerInfo>& info);
+
+    /**
+     * Getter and setter for 'error' member variable.
+     * It's used to indicate that an error occurred while the tile was filled.
+     */
+    [[nodiscard]] std::optional<std::string> error() const;
+    void setError(const std::optional<std::string>& err);
+
+    /**
+     * Getter and setter for 'timestamp' member variable.
+     * It represents when this layer was created.
+     */
+    [[nodiscard]] std::chrono::time_point<std::chrono::steady_clock> timestamp() const;
+    void setTimestamp(const std::chrono::time_point<std::chrono::steady_clock>& ts);
+
+    /**
+     * Getter and setter for 'ttl_' member variable.
+     * It represents how long this layer should live.
+     */
+    [[nodiscard]]std::optional<std::chrono::seconds> ttl() const;
+    void setTtl(const std::optional<std::chrono::seconds>& timeToLive);
+
+    /**
+     * Getter and setter for 'protocolVersion_' member variable.
+     * It represents the protocol version that was used to serialize this layer.
+     */
+    [[nodiscard]] int protocolVersion() const;
+    void setProtocolVersion(int version);
+
+    /**
+     * Getter and setter for 'info' member variable.
+     * It's an extra JSON document to store sizes, construction times,
+     * and other arbitrary meta-information.
+     */
+    [[nodiscard]] nlohmann::json info() const;
+    void setInfo(const nlohmann::json& info);
+
+protected:
+    TileId tileId_;
+    std::string nodeId_;
+    std::string mapId_;
+    std::shared_ptr<LayerInfo> layerInfo_;
+    std::optional<std::string> error_;
+    std::chrono::time_point<std::chrono::steady_clock> timestamp_;
+    std::optional<std::chrono::seconds> ttl_;
+    int protocolVersion_ = 0;
+    nlohmann::json info_;
+};
+
+}
diff --git a/libs/model/include/mapget/model/tileid.h b/libs/model/include/mapget/model/tileid.h
new file mode 100644
index 00000000..72cfc174
--- /dev/null
+++ b/libs/model/include/mapget/model/tileid.h
@@ -0,0 +1,94 @@
+#pragma once
+
+#include <cstdint>
+
+#include "simfil/model/point.h"
+
+namespace mapget
+{
+
+using Point = simfil::geo::Point<double>;
+
+/**
+ * TileId class - represents a rectangular area on the globe, according to
+ * the following tiling scheme.
+ *
+ * Each tile is identified by a zoom level `z` and two grid
+ * coordinates `x` and `y`. *mapget* uses a binary tiling scheme for
+ * the earths surface: The zoom level `z` controls the number of subdivisions for
+ * the WGS84 longitudinal `[-180,180]` axis (columns) and latitudinal `[-90,90]` axis (rows).
+ * The tile `x` coordinate indicates the column, and the `y` coordinate indicates the row.
+ * On level zero, there are two columns and one row. In general, the number of rows is `2^z`,
+ * and the number of columns is `2^(z+1)`.
+ *
+ * Note: Column 0 is at `lon=-180` and row 0 is at lat=`90` (the north pole).
+ */
+struct TileId
+{
+    /**
+     * Constructor to initialize TileId with x, y, z
+     */
+    TileId(uint16_t x, uint16_t y, uint16_t z);
+
+    /**
+     * Constructor to initialize TileId with value
+     */
+    TileId(uint64_t value);  // NOLINT (allow implicit call)
+
+    /**
+     * Function to create a TileId from WGS84 longitude, latitude, and zoom level.
+     */
+    static TileId fromWgs84(double longitude, double latitude, uint16_t zoomLevel);
+
+    /**
+     * Get the center of the tile in Wgs84.
+     */
+    [[nodiscard]] Point center() const;
+
+    /**
+     * Get the south-west (minimum) corner of the tile in Wgs84.
+     */
+    [[nodiscard]] Point sw() const;
+
+    /**
+     * Get the north-east (maximum) corner of the tile in Wgs84.
+     */
+    [[nodiscard]] Point ne() const;
+
+    /**
+     * Get the size of the tile in Wgs84.
+     */
+    [[nodiscard]] Point size() const;
+
+    /**
+     * Function to get x (column) component of the TileId
+     */
+    [[nodiscard]] uint16_t x() const;
+
+    /**
+     * Function to get y (row) component of the TileId
+     */
+    [[nodiscard]] uint16_t y() const;
+
+    /**
+     * Function to get z (zoom level) component of the TileId (zoom level)
+     */
+    [[nodiscard]] uint16_t z() const;
+
+    /**
+     * Operator overload for equality comparison
+     */
+    bool operator==(TileId const&) const;
+
+    /**
+     * Operator overload for less than comparison
+     */
+    bool operator<(TileId const&) const;
+
+    /**
+     * The value representing the TileId, in 0x00xxyyzz format.
+     */
+    uint64_t value_;
+};
+
+}
\ No newline at end of file
diff --git a/libs/model/src/attr.cpp b/libs/model/src/attr.cpp
new file mode 100644
index 00000000..74fd1c83
--- /dev/null
+++ b/libs/model/src/attr.cpp
@@ -0,0 +1,48 @@
+#include "attr.h"
+#include "featurelayer.h"
+
+namespace mapget
+{
+
+Attribute::Attribute(Attribute::Data& data, simfil::ModelConstPtr l, simfil::ModelNodeAddress a)
+    : simfil::ProceduralObject<2, Attribute>(data.fields_, std::move(l), a), data_(data)
+{
+    if (data_.direction_)
+        fields_.emplace_back(
+            Fields::DirectionStr,
+            [](Attribute const& self) {
+                return model_ptr<simfil::ValueNode>::make(
+                    (int64_t)self.data_.direction_,
+                    self.model_);
+            });
+    if (data_.validity_.value_)
+        fields_.emplace_back(
+            Fields::ValidityStr,
+            [](Attribute const& self) {
+                return model_ptr<simfil::ModelNode>::make(self.model_, self.data_.validity_);
+            });
+}
+
+model_ptr<Geometry> Attribute::validity()
+{
+    return model().resolveGeometry(model_ptr<simfil::ModelNode>::make(model_, data_.validity_));
+}
+
+void Attribute::setDirection(const Attribute::Direction& v)
+{
+    data_.direction_ = v;
+}
+
+Attribute::Direction Attribute::direction() const
+{
+    return data_.direction_;
+}
+
+std::string_view Attribute::name()
+{
+    if (auto s = model().fieldNames()->resolve(data_.name_))
+        return *s;
+    throw std::runtime_error("Attribute name is not known to string pool.");
+}
+
+}
\ No newline at end of file
diff --git a/libs/model/src/attrlayer.cpp b/libs/model/src/attrlayer.cpp
new file mode 100644
index 00000000..c1e2f645
--- /dev/null
+++ b/libs/model/src/attrlayer.cpp
@@ -0,0 +1,51 @@
+#include "attrlayer.h"
+#include "featurelayer.h"
+
+namespace mapget
+{
+
+AttributeLayer::AttributeLayer(
+    simfil::ArrayIndex i,
+    simfil::ModelConstPtr l,
+    simfil::ModelNodeAddress a
+)
+    : simfil::Object(i, std::move(l), a)
+{
+}
+
+model_ptr<Attribute>
+AttributeLayer::newAttribute(const std::string_view& name, size_t initialCapacity)
+{
+    auto result = reinterpret_cast<TileFeatureLayer&>(model()).newAttribute(name, initialCapacity);
+    addAttribute(result);
+    return result;
+}
+
+void AttributeLayer::addAttribute(model_ptr<Attribute> a)
+{
+    addField(a->name(), simfil::ModelNode::Ptr(a));
+}
+
+AttributeLayerList::AttributeLayerList(
+    simfil::ArrayIndex i,
+    simfil::ModelConstPtr l,
+    simfil::ModelNodeAddress a
+)
+    : simfil::Object(i, std::move(l), a)
+{
+}
+
+model_ptr<AttributeLayer>
+AttributeLayerList::newLayer(const std::string_view& name, size_t initialCapacity)
+{
+    auto result = modelPtr<TileFeatureLayer>()->newAttributeLayer(initialCapacity);
+    addLayer(name, result);
+    return result;
+}
+
+void AttributeLayerList::addLayer(const std::string_view& name, model_ptr<AttributeLayer> l)
+{
+    addField(name, simfil::ModelNode::Ptr(std::move(l)));
+}
+
+}
\ No newline at end of file
diff --git a/libs/model/src/feature.cpp b/libs/model/src/feature.cpp
new file mode 100644
index 00000000..28a238dd
--- /dev/null
+++ b/libs/model/src/feature.cpp
@@ -0,0 +1,274 @@
+#include "feature.h"
+#include "featurelayer.h"
+
+namespace mapget
+{
+
+Feature::Feature(Feature::Data& d, simfil::ModelConstPtr l, simfil::ModelNodeAddress a)
+    : simfil::MandatoryDerivedModelNodeBase<TileFeatureLayer>(std::move(l), a), data_(d)
+{
+    updateFields();
+}
+
+model_ptr<FeatureId> Feature::id() const
+{
+    return model().resolveFeatureId(*Ptr::make(model_, data_.id_));
+}
+
+std::string_view mapget::Feature::typeId() const
+{
+    return model().resolveFeatureId(*Ptr::make(model_, data_.id_))->typeId();
+}
+
+model_ptr<simfil::GeometryCollection> Feature::geom()
+{
+    if (data_.geom_.value_ == 0) {
+        auto result = model().newGeometryCollection();
+        data_.geom_ = result->addr();
+        updateFields();
+        return result;
+    }
+    return model().resolveGeometryCollection(Ptr::make(model_, data_.geom_));
+}
+
+model_ptr<AttributeLayerList> Feature::attributeLayers()
+{
+    if (data_.attrLayers_.value_ == 0) {
+        auto result = model().newAttributeLayers();
+        data_.attrLayers_ = result->addr();
+        updateFields();
+        return result;
+    }
+    return model().resolveAttributeLayerList(*Ptr::make(model_, data_.attrLayers_));
+}
+
+model_ptr<Object> Feature::attributes()
+{
+    if (data_.attrs_.value_ == 0) {
+        auto result = model().newObject(8);
+        data_.attrs_ = result->addr();
+        updateFields();
+        return result;
+    }
+    return model().resolveObject(Ptr::make(model_, data_.attrs_));
+}
+
+model_ptr<Array> Feature::children()
+{
+    if (data_.children_.value_ == 0) {
+        auto result = model().newArray(8);
+        data_.children_ = result->addr();
+        updateFields();
+        return result;
+    }
+    return model().resolveArray(Ptr::make(model_, data_.children_));
+}
+
+std::vector<simfil::Value> Feature::evaluateAll(const std::string_view& expression)
+{
+    // Note: Here we rely on the assertion that the root_ column
+    // contains only references to feature nodes, in the order
+    // of the feature node column. We could think about protected inheritance
+    // of the ModelPool to safeguard this.
+    return simfil::eval(
+        model().evaluationEnvironment(),
+        *model().compiledExpression(expression),
+        model(),
+        addr().index());
+}
+
+simfil::Value Feature::evaluate(const std::string_view& expression) {
+    auto results = evaluateAll(expression);
+    if (results.empty())
+        return simfil::Value::null();
+    return std::move(results[0]);
+}
+
+simfil::ValueType Feature::type() const
+{
+    return simfil::ValueType::Object;
+}
+
+simfil::ModelNode::Ptr Feature::at(int64_t i) const
+{
+    if (i < fields_.size())
+        return fields_[i].second;
+    return {};
+}
+
+uint32_t Feature::size() const
+{
+    return fields_.size();
+}
+
+simfil::ModelNode::Ptr Feature::get(const simfil::FieldId& f) const
+{
+    for (auto const& [fieldName, fieldValue] : fields_)
+        if (fieldName == f)
+            return fieldValue;
+    return {};
+}
+
+simfil::FieldId Feature::keyAt(int64_t i) const
+{
+    if (i < fields_.size())
+        return fields_[i].first;
+    return {};
+}
+
+bool Feature::iterate(const simfil::ModelNode::IterCallback& cb) const
+{
+    auto& resolver = model();
+    auto resolveAndCb = [&cb, &resolver](auto&& nodeNameAndValue){
+        bool cont = true;
+        resolver.resolve(*nodeNameAndValue.second, simfil::Model::Lambda([&cont, &cb](auto&& resolved){
+            cont = cb(resolved);
+        }));
+        return cont;
+    };
+    return std::all_of(fields_.begin(), fields_.end(), resolveAndCb);
+}
+
+void Feature::updateFields() {
+    fields_.clear();
+
+    // Add type field
+    fields_.emplace_back(Fields::Type, simfil::ValueNode(std::string_view("Feature"), model_));
+
+    // Add id field
+    fields_.emplace_back(Fields::IdStr, Ptr::make(model_, data_.id_));
+    auto idNode = model().resolveFeatureId(*fields_.back().second);
+
+    // Add type id field
+    fields_.emplace_back(
+        Fields::TypeIdStr,
+        model_ptr<simfil::ValueNode>::make(idNode->typeId(), model_));
+
+    // Add common id-part fields
+    if (model().featureIdPrefix())
+        for (auto const& [idPartName, value] : (*model().featureIdPrefix())->fields()) {
+            fields_.emplace_back(idPartName, value);
+        }
+
+    // Add feature-specific id-part fields
+    for (auto const& [idPartName, value] : idNode->fields()) {
+        fields_.emplace_back(idPartName, value);
+    }
+
+    // Add other fields
+    if (data_.geom_.value_)
+        fields_.emplace_back(Fields::Geometry, Ptr::make(model_, data_.geom_));
+    if (data_.attrLayers_.value_ || data_.attrs_.value_)
+        fields_.emplace_back(
+            Fields::PropertiesStr,
+            Ptr::make(
+                model_,
+                simfil::ModelNodeAddress{TileFeatureLayer::FeatureProperties, addr().index()}));
+    if (data_.children_.value_)
+        fields_.emplace_back(Fields::ChildrenStr, Ptr::make(model_, data_.children_));
+}
+
+nlohmann::json Feature::toGeoJson()
+{
+    return toJsonPrivate(*this);
+}
+
+nlohmann::json Feature::toJsonPrivate(const simfil::ModelNode& n)
+{
+    if (n.type() == simfil::ValueType::Object) {
+        auto j = nlohmann::json::object();
+        for (const auto& [fieldId, childNode] : n.fields()) {
+            if (auto resolvedField = model().fieldNames()->resolve(fieldId))
+                j[*resolvedField] = toJsonPrivate(*childNode);
+        }
+        return j;
+    }
+    else if (n.type() == simfil::ValueType::Array) {
+        auto j = nlohmann::json::array();
+        for (const auto& i : n)
+            j.push_back(toJsonPrivate(*i));
+        return j;
+    }
+    else {
+        nlohmann::json j;
+        std::visit(
+            [&j](auto&& v)
+            {
+                if constexpr (!std::is_same_v<std::decay_t<decltype(v)>, std::monostate>)
+                    j = v;
+                else
+                    j = nullptr;
+            },
+            n.value());
+        return j;
+    }
+}
+
+//////////////////////////////////////////
+
+Feature::FeaturePropertyView::FeaturePropertyView(
+    Feature::Data& d,
+    simfil::ModelConstPtr l,
+    simfil::ModelNodeAddress a
+)
+    : simfil::MandatoryDerivedModelNodeBase<TileFeatureLayer>(std::move(l), a), data_(d)
+{
+    if (data_.attrs_.value_)
+        attrs_ = model().resolveObject(Ptr::make(model_, data_.attrs_));
+}
+
+simfil::ValueType Feature::FeaturePropertyView::type() const
+{
+    return simfil::ValueType::Object;
+}
+
+simfil::ModelNode::Ptr Feature::FeaturePropertyView::at(int64_t i) const
+{
+    if (data_.attrLayers_.value_) {
+        if (i == 0)
+            return Ptr::make(model_, data_.attrLayers_);
+        i -= 1;
+    }
+    if (attrs_)
+        return (*attrs_)->at(i);
+    return {};
+}
+
+uint32_t Feature::FeaturePropertyView::size() const
+{
+    return (data_.attrLayers_.value_ ? 1 : 0) + (attrs_ ? (*attrs_)->size() : 0);
+}
+
+simfil::ModelNode::Ptr Feature::FeaturePropertyView::get(const simfil::FieldId& f) const
+{
+    if (data_.attrLayers_.value_ && f == Fields::LayerStr)
+        return Ptr::make(model_, data_.attrLayers_);
+    if (attrs_)
+        return (*attrs_)->get(f);
+    return {};
+}
+
+simfil::FieldId Feature::FeaturePropertyView::keyAt(int64_t i) const
+{
+    if (data_.attrLayers_.value_) {
+        if (i == 0)
+            return Fields::LayerStr;
+        i -= 1;
+    }
+    if (attrs_)
+        return (*attrs_)->keyAt(i);
+    return {};
+}
+
+bool Feature::FeaturePropertyView::iterate(const simfil::ModelNode::IterCallback& cb) const
+{
+    if (data_.attrLayers_.value_) {
+        if (!cb(*model().resolveAttributeLayerList(*Ptr::make(model_, data_.attrLayers_))))
+            return false;
+    }
+    if (attrs_)
+        return (*attrs_)->iterate(cb);
+    return true;
+}
+
+}
diff --git a/libs/model/src/featureid.cpp b/libs/model/src/featureid.cpp
new file mode 100644
index 00000000..82628c5c
--- /dev/null
+++ b/libs/model/src/featureid.cpp
@@ -0,0 +1,81 @@
+#include "featureid.h"
+#include "featurelayer.h"
+
+#include <sstream>
+
+namespace mapget
+{
+
+FeatureId::FeatureId(FeatureId::Data& data, simfil::ModelConstPtr l, simfil::ModelNodeAddress a)
+    : simfil::MandatoryDerivedModelNodeBase<TileFeatureLayer>(std::move(l), a),
+      data_(data),
+      fields_(model().resolveObject(Ptr::make(l, data_.idParts_)))
+{
+}
+
+std::string_view FeatureId::typeId() const
+{
+    if (auto s = model().fieldNames()->resolve(data_.typeId_))
+        return *s;
+    return "err-unresolved-typename";
+}
+
+std::string FeatureId::toString() const
+{
+    std::stringstream result;
+    result << typeId();
+
+    auto addIdPart = [&result](auto&& v)
+    {
+        if constexpr (!std::is_same_v<std::decay_t<decltype(v)>, std::monostate>)
+            result << "." << v;
+    };
+
+    // Add common id-part fields
+    if (data_.useCommonTilePrefix_ && model().featureIdPrefix())
+        for (auto const& [_, value] : (*model().featureIdPrefix())->fields())
+            std::visit(addIdPart, value->value());
+
+    // Add specific id-part fields
+    for (auto const& [_, value] : fields())
+        std::visit(addIdPart, value->value());
+
+    return result.str();
+}
+
+simfil::ValueType FeatureId::type() const
+{
+    return simfil::ValueType::String;
+}
+
+simfil::ScalarValueType FeatureId::value() const
+{
+    return toString();
+}
+
+simfil::ModelNode::Ptr FeatureId::at(int64_t i) const
+{
+    return fields_->at(i);
+}
+
+uint32_t FeatureId::size() const
+{
+    return fields_->size();
+}
+
+simfil::ModelNode::Ptr FeatureId::get(const simfil::FieldId& f) const
+{
+    return fields_->get(f);
+}
+
+simfil::FieldId FeatureId::keyAt(int64_t i) const
+{
+    return fields_->keyAt(i);
+}
+
+bool FeatureId::iterate(const simfil::ModelNode::IterCallback& cb) const
+{
+    return true;
+}
+
+}
diff --git a/libs/model/src/featurelayer.cpp b/libs/model/src/featurelayer.cpp
new file mode 100644
index 00000000..6b232fce
--- /dev/null
+++ b/libs/model/src/featurelayer.cpp
@@ -0,0 +1,264 @@
+#include "featurelayer.h"
+
+#include <map>
+#include <shared_mutex>
+
+namespace mapget
+{
+
+struct TileFeatureLayer::Impl {
+    std::optional<model_ptr<Object>> featureIdPrefix_;
+
+    sfl::segmented_vector<Feature::Data, simfil::detail::ColumnPageSize/4> features_;
+    sfl::segmented_vector<Attribute::Data, simfil::detail::ColumnPageSize> attributes_;
+    sfl::segmented_vector<FeatureId::Data, simfil::detail::ColumnPageSize/2> featureIds_;
+    sfl::segmented_vector<simfil::ArrayIndex, simfil::detail::ColumnPageSize/2> attrLayers_;
+    sfl::segmented_vector<simfil::ArrayIndex, simfil::detail::ColumnPageSize/2> attrLayerLists_;
+
+    // Simfil execution Environment for this tile's string pool.
+    simfil::Environment simfilEnv_;
+
+    // Compiled simfil expressions, by hash of expression string
+    std::map<std::string, simfil::ExprPtr> simfilExpressions_;
+
+    // Mutex to manage access to the expression cache
+    std::shared_mutex expressionCacheLock_;
+
+    explicit Impl(std::shared_ptr<Fields> fields)
+        : simfilEnv_(std::move(fields))
+    {
+    }
+};
+
+TileFeatureLayer::TileFeatureLayer(
+    TileId tileId,
+    std::string const& nodeId,
+    std::string const& mapId,
+    std::shared_ptr<LayerInfo> const& layerInfo,
+    std::shared_ptr<Fields> const& fields
+) :
+    simfil::ModelPool(fields),
+    impl_(std::make_unique<Impl>(fields)),
+    TileLayer(tileId, nodeId, mapId, layerInfo)
+{
+}
+
+TileFeatureLayer::~TileFeatureLayer() = default;
+
+simfil::shared_model_ptr<Feature> TileFeatureLayer::newFeature(
+    const std::string_view& typeId,
+    const KeyValuePairs& featureIdParts)
+{
+    // TODO: Validate ID parts
+    auto featureIdIndex = impl_->featureIds_.size();
+    auto featureIdObject = newObject(featureIdParts.size());
+    impl_->featureIds_.emplace_back(FeatureId::Data{
+        true,
+        fieldNames()->emplace(typeId),
+        featureIdObject->addr()
+    });
+    for (auto const& [k, v] : featureIdParts) {
+        auto&& kk = k;
+        std::visit([&](auto&& x){
+            featureIdObject->addField(kk, x);
+        }, v);
+    }
+
+    auto featureIndex = impl_->features_.size();
+    impl_->features_.emplace_back(Feature::Data{
+        simfil::ModelNodeAddress{FeatureIds, (uint32_t)featureIdIndex},
+        simfil::ModelNodeAddress{Null, 0},
+        simfil::ModelNodeAddress{Null, 0},
+        simfil::ModelNodeAddress{Null, 0},
+        simfil::ModelNodeAddress{Null, 0},
+    });
+    auto result = Feature(
+        impl_->features_.back(),
+        shared_from_this(),
+        simfil::ModelNodeAddress{Features, (uint32_t)featureIndex});
+
+    // Note: Here we rely on the assertion that the root_ collection
+    // contains only references to feature nodes, in the order
+    // of the feature node column.
+    addRoot(simfil::ModelNode::Ptr(result));
+    return result;
+}
+
+model_ptr<FeatureId>
+TileFeatureLayer::newFeatureId(
+    const std::string_view& typeId,
+    const KeyValuePairs& featureIdParts)
+{
+    // TODO: Validate ID parts
+    auto featureIdIndex = impl_->featureIds_.size();
+    impl_->featureIds_.emplace_back(FeatureId::Data{
+        true,
+        fieldNames()->emplace(typeId),
+        newObject(featureIdParts.size())->addr()
+    });
+    return FeatureId(impl_->featureIds_.back(), shared_from_this(), {FeatureIds, (uint32_t)featureIdIndex});
+}
+
+std::optional<model_ptr<Object>> TileFeatureLayer::featureIdPrefix()
+{
+    return impl_->featureIdPrefix_;
+}
+
+model_ptr<Attribute>
+TileFeatureLayer::newAttribute(const std::string_view& name, size_t initialCapacity)
+{
+    auto attrIndex = impl_->attributes_.size();
+    impl_->attributes_.emplace_back(Attribute::Data{
+        Attribute::Empty,
+        {Null, 0},
+        objectMemberStorage().new_array(initialCapacity),
+        fieldNames()->emplace(name)
+    });
+    return Attribute(
+        impl_->attributes_.back(),
+        shared_from_this(),
+        {Attributes, (uint32_t)attrIndex});
+}
+
+model_ptr<AttributeLayer> TileFeatureLayer::newAttributeLayer(size_t initialCapacity)
+{
+    auto layerIndex = impl_->attrLayers_.size();
+    impl_->attrLayers_.emplace_back(objectMemberStorage().new_array(initialCapacity));
+    return AttributeLayer(
+        impl_->attrLayers_.back(),
+        shared_from_this(),
+        {AttributeLayers, (uint32_t)layerIndex});
+}
+
+model_ptr<AttributeLayerList> TileFeatureLayer::newAttributeLayers(size_t initialCapacity)
+{
+    auto listIndex = impl_->attrLayerLists_.size();
+    impl_->attrLayerLists_.emplace_back(objectMemberStorage().new_array(initialCapacity));
+    return AttributeLayerList(
+        impl_->attrLayerLists_.back(),
+        shared_from_this(),
+        {AttributeLayerLists, (uint32_t)listIndex});
+}
+
+model_ptr<AttributeLayer> TileFeatureLayer::resolveAttributeLayer(simfil::ModelNode const& n) const
+{
+    return AttributeLayer(
+        impl_->attrLayers_[n.addr().index()],
+        shared_from_this(),
+        n.addr());
+}
+
+model_ptr<AttributeLayerList> TileFeatureLayer::resolveAttributeLayerList(simfil::ModelNode const& n) const
+{
+    return AttributeLayerList(
+        impl_->attrLayerLists_[n.addr().index()],
+        shared_from_this(),
+        n.addr());
+}
+
+model_ptr<Attribute> TileFeatureLayer::resolveAttribute(simfil::ModelNode const& n) const
+{
+    return Attribute(
+        impl_->attributes_[n.addr().index()],
+        shared_from_this(),
+        n.addr());
+}
+
+model_ptr<Feature> TileFeatureLayer::resolveFeature(simfil::ModelNode const& n) const
+{
+    return Feature(
+        impl_->features_[n.addr().index()],
+        shared_from_this(),
+        n.addr());
+}
+
+model_ptr<FeatureId> TileFeatureLayer::resolveFeatureId(simfil::ModelNode const& n) const
+{
+    return FeatureId(
+        impl_->featureIds_[n.addr().index()],
+        shared_from_this(),
+        n.addr());
+}
+
+void TileFeatureLayer::resolve(const simfil::ModelNode& n, const simfil::Model::ResolveFn& cb) const
+{
+    switch (n.addr().column())
+    {
+    case Features: {
+        cb(*resolveFeature(n));
+        return;
+    }
+    case FeatureProperties: {
+        cb(Feature::FeaturePropertyView(
+            impl_->features_[n.addr().index()],
+            shared_from_this(),
+            n.addr()
+        ));
+        return;
+    }
+    case FeatureIds: {
+        cb(*resolveFeatureId(n));
+        return;
+    }
+    case Attributes: {
+        cb(*resolveAttribute(n));
+        return;
+    }
+    case AttributeLayers: {
+        cb(*resolveAttributeLayer(n));
+        return;
+    }
+    case AttributeLayerLists: {
+        cb(*resolveAttributeLayerList(n));
+        return;
+    }
+    }
+
+    simfil::ModelPool::resolve(n, cb);
+}
+
+simfil::Environment& TileFeatureLayer::evaluationEnvironment()
+{
+    return impl_->simfilEnv_;
+}
+
+simfil::ExprPtr const& TileFeatureLayer::compiledExpression(const std::string_view& expr)
+{
+    std::shared_lock sharedLock(impl_->expressionCacheLock_);
+    std::string exprString{expr};
+    auto it = impl_->simfilExpressions_.find(exprString);
+    if (it != impl_->simfilExpressions_.end()) {
+        return it->second;
+    }
+    sharedLock.unlock();
+    std::unique_lock uniqueLock(impl_->expressionCacheLock_);
+    auto [newIt, _] = impl_->simfilExpressions_.emplace(
+        std::move(exprString),
+        simfil::compile(impl_->simfilEnv_, expr, false)
+    );
+    return newIt->second;
+}
+
+void TileFeatureLayer::setPrefix(const KeyValuePairs& prefix)
+{
+    auto idPrefix = newObject(prefix.size());
+    for (auto const& [k, v] : prefix) {
+        auto&& kk = k;
+        std::visit([&](auto&& x){
+            idPrefix->addField(kk, x);
+        }, v);
+    }
+    impl_->featureIdPrefix_ = idPrefix;
+}
+
+TileFeatureLayer::Iterator TileFeatureLayer::begin() const
+{
+    return TileFeatureLayer::Iterator{*this, 0};
+}
+
+TileFeatureLayer::Iterator TileFeatureLayer::end() const
+{
+    return TileFeatureLayer::Iterator{*this, numRoots()};
+}
+
+}
diff --git a/libs/model/src/fields.cpp b/libs/model/src/fields.cpp
new file mode 100644
index 00000000..7dad48a0
--- /dev/null
+++ b/libs/model/src/fields.cpp
@@ -0,0 +1,16 @@
+#include "fields.h"
+
+namespace mapget
+{
+
+Fields::Fields() {
+    addStaticKey(IdStr, "id");
+    addStaticKey(TypeIdStr, "typeId");
+    addStaticKey(LayerStr, "layer");
+    addStaticKey(ChildrenStr, "children");
+    addStaticKey(DirectionStr, "direction");
+    addStaticKey(ValidityStr, "validity");
+    addStaticKey(PropertiesStr, "properties");
+}
+
+}
diff --git a/libs/model/src/info.cpp b/libs/model/src/info.cpp
new file mode 100644
index 00000000..c8b91a2a
--- /dev/null
+++ b/libs/model/src/info.cpp
@@ -0,0 +1,8 @@
+#include "info.h"
+
+namespace mapget
+{
+
+
+
+}
\ No newline at end of file
diff --git a/libs/model/src/layer.cpp b/libs/model/src/layer.cpp
new file mode 100644
index 00000000..9afef4f3
--- /dev/null
+++ b/libs/model/src/layer.cpp
@@ -0,0 +1,93 @@
+#include "layer.h"
+
+namespace mapget
+{
+
+TileLayer::TileLayer(
+    const TileId& id,
+    std::string node_id,
+    std::string map_id,
+    const std::shared_ptr<LayerInfo>& info
+)
+    : tileId_(id),
+      nodeId_(std::move(node_id)),
+      mapId_(std::move(map_id)),
+      layerInfo_(info),
+      timestamp_(std::chrono::steady_clock::now())
+{
+    // constructor body
+}
+
+TileId TileLayer::tileId() const {
+    return tileId_;
+}
+
+std::string TileLayer::nodeId() const {
+    return nodeId_;
+}
+
+std::string TileLayer::mapId() const {
+    return mapId_;
+}
+
+std::shared_ptr<LayerInfo> TileLayer::layerInfo() const {
+    return layerInfo_;
+}
+
+std::optional<std::string> TileLayer::error() const {
+    return error_;
+}
+
+std::chrono::time_point<std::chrono::steady_clock> TileLayer::timestamp() const {
+    return timestamp_;
+}
+
+std::optional<std::chrono::seconds> TileLayer::ttl() const {
+    return ttl_;
+}
+
+int TileLayer::protocolVersion() const {
+    return protocolVersion_;
+}
+
+nlohmann::json TileLayer::info() const {
+    return info_;
+}
+
+void TileLayer::setTileId(const TileId& id) {
+    tileId_ = id;
+}
+
+void TileLayer::setNodeId(const std::string& id) {
+    nodeId_ = id;
+}
+
+void TileLayer::setMapId(const std::string& id) {
+    mapId_ = id;
+}
+
+void TileLayer::setLayerInfo(const std::shared_ptr<LayerInfo>& info) {
+    layerInfo_ = info;
+}
+
+void TileLayer::setError(const std::optional<std::string>& err) {
+    error_ = err;
+}
+
+void TileLayer::setTimestamp(const std::chrono::time_point<std::chrono::steady_clock>& ts) {
+    timestamp_ = ts;
+}
+
+void TileLayer::setTtl(const std::optional<std::chrono::seconds>& timeToLive) {
+    ttl_ = timeToLive;
+}
+
+void TileLayer::setProtocolVersion(int version) {
+    protocolVersion_ = version;
+}
+
+void TileLayer::setInfo(const nlohmann::json& info) {
+    this->info_ = info;
+}
+
+} // namespace mapget
diff --git a/libs/model/src/tileid.cpp b/libs/model/src/tileid.cpp
new file mode 100644
index 00000000..425b0dec
--- /dev/null
+++ b/libs/model/src/tileid.cpp
@@ -0,0 +1,97 @@
+#include "tileid.h"
+#include <cmath>
+#include <algorithm>
+
+namespace mapget
+{
+
+// Constants for the range of latitude and longitude
+constexpr auto MIN_LON = -180.0;
+constexpr auto MAX_LON = 180.0;
+constexpr auto MIN_LAT = -90.0;
+constexpr auto MAX_LAT = 90.0;
+constexpr auto LON_EXTENT = MAX_LON - MIN_LON;
+constexpr auto LAT_EXTENT = MAX_LAT - MIN_LAT;
+
+TileId::TileId(uint16_t x, uint16_t y, uint16_t z) {
+    value_ = ((uint64_t)x << 32) | ((uint64_t)y << 16) | z;
+}
+
+TileId::TileId(uint64_t value) : value_(value) {
+    // No extra work needed here as we directly assign the given value
+}
+
+uint16_t TileId::x() const {
+    return (uint16_t) (value_ >> 32);
+}
+
+uint16_t TileId::y() const {
+    return (uint16_t) ((value_ >> 16) & 0xFFFF);
+}
+
+uint16_t TileId::z() const {
+    return (uint16_t) (value_ & 0xFFFF);
+}
+
+bool TileId::operator== (TileId const& other) const {
+    return value_ == other.value_;
+}
+
+bool TileId::operator< (TileId const& other) const {
+    return value_ < other.value_;
+}
+
+TileId TileId::fromWgs84(double longitude, double latitude, uint16_t zoomLevel)
+{
+    longitude = fmod(longitude, LON_EXTENT) + MAX_LON;
+    if (longitude < 0.)
+        longitude += LON_EXTENT;
+
+    // Calculate the number of subdivisions
+    auto numCols = static_cast<uint16_t>(1 << (zoomLevel + 1));
+    auto numRows = static_cast<uint16_t>(1 << zoomLevel);
+
+    // Convert to grid coordinates
+    auto x = static_cast<int64_t>((longitude / LON_EXTENT) * numCols);
+    auto y = static_cast<int64_t>(((MAX_LAT - latitude) / LAT_EXTENT) * numRows);
+
+    x %= numCols;
+    y %= numRows * 2;
+    y = std::abs(y);  // -y: Crossing the North Pole
+
+    // We have crossed the South Pole
+    if (y >= numRows)
+        y = numRows - (y - numRows) - 1;
+
+    return {(uint16_t)x, (uint16_t)y, zoomLevel};
+}
+
+Point TileId::center() const {
+    auto extent = size();
+    auto lon = MIN_LON + (static_cast<double>(x()) + 0.5) * extent.x;
+    // Subtract from MAX_LAT because y=0 is the North Pole
+    auto lat = MAX_LAT - (static_cast<double>(y()) + 0.5) * extent.y;
+    return {lon, lat};
+}
+
+Point TileId::sw() const {
+    auto extent = size();
+    double lon = MIN_LON + static_cast<double>(x()) * extent.x;
+    // Subtract from MAX_LAT because y=0 is the North Pole
+    double lat = MAX_LAT - static_cast<double>(y() + 1) * extent.y;
+    return {lon, lat};
+}
+
+Point TileId::ne() const {
+    auto extent = size();
+    double lon = MIN_LON + static_cast<double>(x() + 1) * extent.x;
+    // Subtract from MAX_LAT because y=0 is the North Pole
+    double lat = MAX_LAT - static_cast<double>(y()) * extent.y;
+    return {lon, lat};
+}
+
+Point TileId::size() const {
+    return {LON_EXTENT / (1 << (z() + 1)), LAT_EXTENT / (1 << z())};
+}
+
+} // namespace mapget


Test patch:
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
new file mode 100644
index 00000000..7f1ed802
--- /dev/null
+++ b/test/CMakeLists.txt
@@ -0,0 +1,22 @@
+project(test.mapget)
+
+if (NOT TARGET Catch2)
+  FetchContent_Declare(Catch2
+    GIT_REPOSITORY "https://github.com/catchorg/Catch2.git"
+    GIT_TAG        "v3.3.2"
+    GIT_SHALLOW    ON)
+  FetchContent_MakeAvailable(Catch2)
+  list(APPEND CMAKE_MODULE_PATH "${CATCH2_SOURCE_DIR}/contrib")
+endif()
+
+add_executable(test.mapget
+  model.cpp)
+
+target_link_libraries(test.mapget
+  PUBLIC
+    mapget-model
+    Catch2::Catch2WithMain)
+
+include(Catch)
+include(CTest)
+catch_discover_tests(test.mapget)
diff --git a/test/model.cpp b/test/model.cpp
new file mode 100644
index 00000000..602a94ef
--- /dev/null
+++ b/test/model.cpp
@@ -0,0 +1,132 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include "mapget/model/featurelayer.h"
+#include "nlohmann/json.hpp"
+
+#include <sstream>
+
+using namespace mapget;
+
+TEST_CASE("FeatureLayer", "[test.featurelayer]")
+{
+    // Create layer info which has a single feature type with
+    // a single allowed feature id composition.
+    auto layerInfo = std::make_shared<LayerInfo>();
+    layerInfo->featureTypes.emplace_back(FeatureTypeInfo{
+        "Way",
+        {{
+            UniqueIdPart{"areaId", "String which identifies the map area.", IdPartDataType::STR, false, false},
+            UniqueIdPart{"wayId", "Globally Unique 32b integer.",
+              IdPartDataType::U32, false, false}
+        }}});
+
+    // Create empty shared autofilled field-name dictionary
+    auto fieldNames = std::make_shared<Fields>();
+
+    // Create a basic TileFeatureLayer
+    auto tile = std::make_shared<TileFeatureLayer>(
+        TileId::fromWgs84(42., 11., 13),
+        "TastyTomatoSaladNode",
+        "GarlicChickenMap",
+        layerInfo,
+        fieldNames);
+    tile->setPrefix({{"areaId", "TheBestArea"}});
+
+    // Create a feature with line geometry
+    auto feature1 = tile->newFeature("Way", {{"wayId", 42}});
+    auto line = feature1->geom()->newGeometry(Geometry::GeomType::Line, 2);
+    line->append({41., 10.});
+    line->append({43., 11.});
+
+    // Add a fixed attribute
+    feature1->attributes()->addField("main_ingredient", "Pepper");
+
+    // Add an attribute layer
+    auto attrLayer = feature1->attributeLayers()->newLayer("cheese");
+    auto attr = attrLayer->newAttribute("mozzarella");
+    attr->setDirection(Attribute::Direction::Positive);
+    attr->addField("smell", "neutral");
+
+    SECTION("toGeoJSON")
+    {
+        constexpr auto expected = R"({"areaId":"TheBestArea","geometry":{"geometries":[{"coordinates":[[41.0,10.0,0.0],[43.0,11.0,0.0]],"type":"LineString"}],"type":"GeometryCollection"},"id":"Way.TheBestArea.42","properties":{"layer":{"cheese":{"mozzarella":{"direction":1,"smell":"neutral"}}},"main_ingredient":"Pepper"},"type":"Feature","typeId":"Way","wayId":42})";
+        std::stringstream featureGeoJson;
+        featureGeoJson << feature1->toGeoJson();
+        REQUIRE(featureGeoJson.str() == expected);
+    }
+
+    SECTION("Basic field access")
+    {
+        REQUIRE(feature1->typeId() == "Way");
+        REQUIRE(feature1->id()->toString() == "Way.TheBestArea.42");
+    }
+
+    SECTION("Evaluate simfil filter")
+    {
+        REQUIRE(feature1->evaluate("**.mozzarella.smell").toString() == "neutral");
+        REQUIRE(feature1->evaluate("properties.main_ingredient").toString() == "Pepper");
+        REQUIRE(
+            feature1->evaluate("geo(geometry.geometries.*) within bbox(40., 9., 45., 12.)")
+                .toString() == "true");
+    }
+
+    SECTION("Range-based for loop")
+    {
+        for (auto feature : *tile) {
+            REQUIRE(feature->id()->toString() == feature1->id()->toString());
+        }
+    }
+}
+
+// Helper function to compare two points with some tolerance
+void REQUIRE_EQUAL(const Point& p1, const Point& p2, double eps = 1e-6) {
+    REQUIRE(std::abs(p1.x - p2.x) < eps);
+    REQUIRE(std::abs(p1.y - p2.y) < eps);
+}
+
+TEST_CASE("TileId", "[TileId]") {
+    using namespace mapget;
+
+    SECTION("fromWgs84: zoom level 0") {
+        TileId tile = TileId::fromWgs84(0, 0, 0);
+        REQUIRE(tile.x() == 1);
+        REQUIRE(tile.y() == 0);
+        REQUIRE(tile.z() == 0);
+    }
+
+    SECTION("fromWgs84: positive longitude, positive latitude") {
+        TileId tile = TileId::fromWgs84(90, 45, 1);
+        REQUIRE(tile.x() == 3);
+        REQUIRE(tile.y() == 0);
+        REQUIRE(tile.z() == 1);
+    }
+
+    SECTION("fromWgs84: negative longitude, positive latitude") {
+        TileId tile = TileId::fromWgs84(-90, 45, 1);
+        REQUIRE(tile.x() == 1);
+        REQUIRE(tile.y() == 0);
+        REQUIRE(tile.z() == 1);
+    }
+
+    SECTION("fromWgs84: positive longitude, negative latitude") {
+        TileId tile = TileId::fromWgs84(90, -45, 1);
+        REQUIRE(tile.x() == 3);
+        REQUIRE(tile.y() == 1);
+        REQUIRE(tile.z() == 1);
+    }
+
+    SECTION("fromWgs84: negative longitude, negative latitude") {
+        TileId tile = TileId::fromWgs84(-90, -45, 1);
+        REQUIRE(tile.x() == 1);
+        REQUIRE(tile.y() == 1);
+        REQUIRE(tile.z() == 1);
+    }
+
+    SECTION("Tile center/SW/NE/size calculation") {
+        TileId tile(0, 0, 0);
+        REQUIRE_EQUAL(tile.center(), {-90, 0});
+        REQUIRE_EQUAL(tile.sw(), {-180, -90});
+        REQUIRE_EQUAL(tile.ne(), {0, 90});
+        REQUIRE_EQUAL(tile.size(), {180, 180});
+    }
+}
\ No newline at end of file