diff --git a/CMakeLists.txt b/CMakeLists.txt index f6a382bd..7c074ea7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,7 +3,7 @@ project(erdblick) include(FetchContent) -set(CMAKE_CXX_STANDARD 14) +set(CMAKE_CXX_STANDARD 17) # Treat warnings as errors, with some exceptions for Cesium. set (ERDBLICK_CXX_FLAGS diff --git a/libs/core/include/erdblick/buffer.h b/libs/core/include/erdblick/buffer.h index c4fece0d..8f973a36 100644 --- a/libs/core/include/erdblick/buffer.h +++ b/libs/core/include/erdblick/buffer.h @@ -16,7 +16,6 @@ class SharedUint8Array explicit SharedUint8Array(std::string const& data); [[nodiscard]] uint32_t getSize() const; __UINT64_TYPE__ getPointer(); - std::shared_ptr> getArray(); void writeToArray(const char* start, const char* end); void writeToArray(std::string const& content); diff --git a/libs/core/include/erdblick/testdataprovider.h b/libs/core/include/erdblick/testdataprovider.h index 887b7b69..6fe79bb4 100644 --- a/libs/core/include/erdblick/testdataprovider.h +++ b/libs/core/include/erdblick/testdataprovider.h @@ -51,23 +51,36 @@ class TestDataProvider fieldNames_); result->setPrefix({{"areaId", "TheBestArea"}}); - // Create a feature with line geometry - auto feature1 = result->newFeature("Way", {{"wayId", 42}}); + // Create a function to generate a random coordinate between two given points + auto randomCoordinateBetween = [&](const auto& point1, const auto& point2) { + auto x = point1.x + (point2.x - point1.x) * (rand() / static_cast(RAND_MAX)); + auto y = point1.y + (point2.y - point1.y) * (rand() / static_cast(RAND_MAX)); + auto z = 100. / static_cast(level); + return mapget::Point{x, y, z}; + }; - // Use high-level geometry API - auto ne = tileId.ne(); - auto sw = tileId.sw(); - ne.z = sw.z = 100./static_cast(level); - feature1->addLine({ne, sw}); + // Seed the random number generator for consistency + srand(time(nullptr)); - // Add a fixed attribute - feature1->attributes()->addField("main_ingredient", "Pepper"); + // Create 10 random lines inside the bounding box defined by ne and sw + for (int i = 0; i < 10; i++) { + // Create a feature with line geometry + auto feature = result->newFeature("Way", {{"wayId", 42 + i}}); - // Add an attribute layer - auto attrLayer = feature1->attributeLayers()->newLayer("cheese"); - auto attr = attrLayer->newAttribute("mozzarella"); - attr->setDirection(mapget::Attribute::Direction::Positive); - attr->addField("smell", "neutral"); + // Generate random start and end points for the line + auto start = randomCoordinateBetween(tileId.ne(), tileId.sw()); + auto end = randomCoordinateBetween(tileId.ne(), tileId.sw()); + feature->addLine({start, end}); + + // Add a fixed attribute + feature->attributes()->addField("main_ingredient", "Pepper"); + + // Add an attribute layer + auto attrLayer = feature->attributeLayers()->newLayer("cheese"); + auto attr = attrLayer->newAttribute("mozzarella"); + attr->setDirection(mapget::Attribute::Direction::Positive); + attr->addField("smell", "neutral"); + } return result; } diff --git a/libs/core/src/bindings.cpp b/libs/core/src/bindings.cpp index e7c14dc1..2dc93cd2 100644 --- a/libs/core/src/bindings.cpp +++ b/libs/core/src/bindings.cpp @@ -7,6 +7,8 @@ #include "style.h" #include "testdataprovider.h" +#include "mapget/log.h" + using namespace erdblick; namespace em = emscripten; @@ -30,6 +32,19 @@ EMSCRIPTEN_BINDINGS(FeatureLayerRendererBind) ////////// FeatureLayerStyle em::class_("FeatureLayerStyle").constructor(); + ////////// Feature + using FeaturePtr = mapget::model_ptr; + em::class_("Feature") + .function( + "id", + std::function( + [](FeaturePtr& self) { return self->id()->toString(); })) + .function( + "geojson", + std::function( + [](FeaturePtr& self) { + return self->toGeoJson().dump(4); })); + ////////// TileFeatureLayer em::class_("TileFeatureLayer") .smart_ptr>( @@ -48,6 +63,17 @@ EMSCRIPTEN_BINDINGS(FeatureLayerRendererBind) result.set("y", self.tileId().center().y); result.set("z", self.tileId().z()); return result; + })) + .function( + "at", + std::function< + mapget::model_ptr(mapget::TileFeatureLayer const&, int i)>( + [](mapget::TileFeatureLayer const& self, int i) + { + if (i < 0 || i >= self.numRoots()) { + mapget::log().error("TileFeatureLayer::at(): Index {} is oob.", i); + } + return self.at(i); })); ////////// FeatureLayerRenderer diff --git a/libs/core/src/buffer.cpp b/libs/core/src/buffer.cpp index da1926b2..edb4a09c 100644 --- a/libs/core/src/buffer.cpp +++ b/libs/core/src/buffer.cpp @@ -1,5 +1,7 @@ #include "buffer.h" +#include + namespace erdblick { @@ -38,16 +40,10 @@ std::string SharedUint8Array::toString() const return {array_.begin(), array_.end()}; } -std::shared_ptr> SharedUint8Array::getArray() -{ - return std::make_shared>(array_); -} - void SharedUint8Array::writeToArray(const std::vector& content) { - array_.reserve(content.size()); - for (auto& byte : content) - array_.emplace_back((uint8_t)byte); + array_.resize(content.size()); + std::memcpy(array_.data(), content.data(), content.size()); } } \ No newline at end of file diff --git a/libs/core/src/renderer.cpp b/libs/core/src/renderer.cpp index 784376d5..14490bea 100644 --- a/libs/core/src/renderer.cpp +++ b/libs/core/src/renderer.cpp @@ -16,6 +16,8 @@ #include "CesiumGltf/Model.h" #include "CesiumGltfWriter/GltfWriter.h" #include "CesiumGltf/ExtensionExtMeshFeatures.h" +#include "CesiumGltf/ExtensionModelExtStructuralMetadata.h" +#include "CesiumGltf/ExtensionMeshPrimitiveExtStructuralMetadata.h" #include "renderer.h" @@ -139,6 +141,8 @@ struct RuleGeometry meshFeatureExtFeatureIds.attribute = 0; meshFeatureExtFeatureIds.featureCount = numDistinctFeatureIDs(); meshFeatureExtFeatureIds.nullFeatureId = -1; + meshFeatureExtFeatureIds.label = "mapgetFeatureIndex"; + meshFeatureExtFeatureIds.propertyTable = 0; primitive.extensions[CesiumGltf::ExtensionExtMeshFeatures::ExtensionName] = meshFeatureExt; auto& posAccessor = model.accessors.emplace_back(); @@ -236,10 +240,18 @@ mapget::Point FeatureLayerRenderer::render( // NOLINT (render can be made stati const std::shared_ptr& layer, SharedUint8Array& result) { - uint32_t bufferSize = 0; + uint32_t globalBufferSize = 0; auto tileOrigin = wgsToEuclidean(layer->tileId().center()); std::map, std::unique_ptr> geomForRule; + // We store feature ID's in an extra dummy property buffer. + // This attributes features with their index by their index, + // so the information stored is fairly useless. + // But cesium demands us to declare at least + // one per-feature property for picking to work. So this is the way. + std::vector featureIdBuffer; + featureIdBuffer.reserve(layer->numRoots()); + // The Feature ID corresponds to the index of the feature // within the TileFeatureLayer. uint32_t featureId = 0; @@ -253,23 +265,71 @@ mapget::Point FeatureLayerRenderer::render( // NOLINT (render can be made stati std::unique_ptr{}); if (wasInserted) { it->second = - std::make_unique(geomType, tileOrigin, rule, bufferSize); + std::make_unique(geomType, tileOrigin, rule, globalBufferSize); } it->second->addFeature(feature, featureId); } } } + featureIdBuffer.emplace_back(featureId); ++featureId; } + globalBufferSize += featureIdBuffer.size() * sizeof(uint32_t); // Convert to GLTF std::vector buffer; - buffer.resize(bufferSize); + buffer.resize(globalBufferSize); int64_t bufferOffset = 0; CesiumGltf::Model model; model.buffers.emplace_back(); // Add single implicit buffer model.asset.version = "2.0"; model.extensionsUsed.emplace_back(CesiumGltf::ExtensionExtMeshFeatures::ExtensionName); + model.extensionsUsed.emplace_back(CesiumGltf::ExtensionModelExtStructuralMetadata::ExtensionName); + + // Prepare feature metadata table description - see + // https://github.com/CesiumGS/glTF/tree/3d-tiles-next/extensions/2.0/Vendor/EXT_structural_metadata + CesiumGltf::ExtensionModelExtStructuralMetadata metadataModelExt; + CesiumGltf::ExtensionExtStructuralMetadataSchema metadataSchema; + CesiumGltf::ExtensionExtStructuralMetadataClass metadataClass; + CesiumGltf::ExtensionExtStructuralMetadataClassProperty metadataFeatureIdProperty; + CesiumGltf::ExtensionExtStructuralMetadataPropertyTable metadataPropertyTable; + CesiumGltf::ExtensionExtStructuralMetadataPropertyTableProperty metadataFeatureIdTableColumn; + metadataFeatureIdProperty.type = "UINT32"; + metadataFeatureIdProperty.description = "Mapget feature index within the TileFeatureLayer."; + metadataClass.name = "mapgetFeatureMetadataClass"; + metadataClass.properties["mapgetFeatureId"] = metadataFeatureIdProperty; + metadataSchema.id = "mapgetFeatureMetadataSchema"; + metadataSchema.classes[*metadataClass.name] = metadataClass; + metadataFeatureIdTableColumn.values = 0; // Values are stored in first accessor + metadataPropertyTable.name = "mapgetFeaturePropertyTable"; + metadataPropertyTable.classProperty = *metadataClass.name; + metadataPropertyTable.count = (int64_t)featureIdBuffer.size(); + metadataPropertyTable.properties["mapgetFeatureId"] = metadataFeatureIdTableColumn; + metadataModelExt.schema = metadataSchema; + metadataModelExt.propertyTables.emplace_back(std::move(metadataPropertyTable)); + model.extensions[CesiumGltf::ExtensionModelExtStructuralMetadata::ExtensionName] = metadataModelExt; + + // Create Accessor and BufferView for Feature ID property values + auto& featIdAccessor = model.accessors.emplace_back(); + featIdAccessor.bufferView = 0; + featIdAccessor.byteOffset = 0; + featIdAccessor.componentType = CesiumGltf::Accessor::ComponentType::UNSIGNED_INT; + featIdAccessor.count = static_cast(featureIdBuffer.size()); + featIdAccessor.type = CesiumGltf::Accessor::Type::SCALAR; + featIdAccessor.min = {featureIdBuffer.empty() ? .0 : (double)featureIdBuffer.front()}; + featIdAccessor.max = {featureIdBuffer.empty() ? .0 : (double)featureIdBuffer.back()}; + + auto& featIdBufferView = model.bufferViews.emplace_back(); + featIdBufferView.buffer = 0; // All buffer views must refer to the implicit buffer 0 + featIdBufferView.byteOffset = bufferOffset; + featIdBufferView.byteLength = static_cast(featureIdBuffer.size() * sizeof(uint32_t)); + featIdBufferView.target = CesiumGltf::BufferView::Target::ARRAY_BUFFER; + + std::memcpy( + buffer.data() + bufferOffset, + featureIdBuffer.data(), + static_cast(featIdBufferView.byteLength)); + bufferOffset += featIdBufferView.byteLength; auto& scene = model.scenes.emplace_back(); for (auto&& [_, ruleGeom] : geomForRule) @@ -302,8 +362,8 @@ void FeatureLayerRenderer::makeTileset( // NOLINT (could be made static) Cesium3DTiles::Tileset tileset; tileset.asset.version = "1.1"; - tileset.geometricError = geometricError; + tileset.extensionsUsed.emplace_back(CesiumGltf::ExtensionExtMeshFeatures::ExtensionName); glm::dquat noRotation = glm::angleAxis(CesiumUtility::Math::degreesToRadians(0.0), glm::dvec3(1.0, 0.0, 0.0)); diff --git a/static/index.css b/static/index.css index f9b5d12f..91d790de 100644 --- a/static/index.css +++ b/static/index.css @@ -19,20 +19,101 @@ body { -o-user-select:none; } -.mapviewer-label { - position: fixed; - width: max-content; -} - -#mapviewer-canvas-container canvas { - width: 100% !important; - height: 100% !important; +#cesiumContainer .cesium-widget-credits { + display:none !important; } -#info { +/* Shared styles for both panels */ +.panel { color: white; font-size: large; - background-color: transparent; + background-color: rgba(91, 91, 91, 0.7); font-family: monospace; - position: relative; + position: absolute; + top: 10px; + border-radius: 10px; + overflow: hidden; + height: 22px; + padding: 1em; + width: calc(50% - 65px); +} + +.panel.expanded { + max-height: calc(100vh - 60px); + height: auto; +} + +@media (max-width: 768px) { + .panel { + width: calc(100% - 70px); + position: relative; + left: auto !important; + right: auto !important; + top: auto; + margin: 10px; + } + + .panel.expanded { + max-height: calc(100vh - 120px); + } +} + +/* Base style for the toggle-indicator */ +.toggle-indicator::before { + content: ''; + border-style: solid; + display: inline-block; + margin-right: 5px; /* A bit of margin for spacing */ + vertical-align: middle; /* Align with the text */ +} + +.panel .toggle-indicator { + width: 18px; + display: inline-block; +} + +/* Triangle pointing right for the collapsed state */ +.panel .toggle-indicator::before { + border-width: 5px 5px 5px 8px; + border-color: transparent transparent transparent white; +} + +/* Triangle pointing down for the expanded state */ +.panel.expanded .toggle-indicator::before { + border-width: 8px 5px 0 5px; + border-color: white transparent transparent transparent; +} + +/* Specific styles for the info panel */ + +#info { + left: 10px; +} + +#info > #log { + display: none; +} + +#info.expanded > #log { + display: block; +} + +/* Specific styles for the selection panel */ + +#selectionPanel { + right: 30px; +} + +#selectionPanel.expanded { + overflow-y: scroll; + scrollbar-width: thin; + overflow-x: hidden; +} + +#selectionPanel > pre { + display: none; +} + +#selectionPanel.expanded > pre { + display: block; } diff --git a/static/index.html b/static/index.html index 337ebdfb..4dbc548d 100644 --- a/static/index.html +++ b/static/index.html @@ -7,13 +7,15 @@ - + + + - + @@ -24,17 +26,25 @@ - +
-
- erdblick v0.1.0 // + +
+ + erdblick v0.2.0 // //
+
+ + Selected Feature: +
 
+
+ \ No newline at end of file diff --git a/static/index.js b/static/index.js index 40013fc5..f2a72bcd 100644 --- a/static/index.js +++ b/static/index.js @@ -24,4 +24,29 @@ libErdblickCore().then(coreLib => window.zoomToBatch = (batchId) => { mapView.viewer.zoomTo(mapModel.registeredBatches.get(batchId).tileSet); } + + mapView.selectionTopic.subscribe(selectedFeatureWrapper => { + if (!selectedFeatureWrapper) { + $("#selectionPanel").hide() + return + } + + selectedFeatureWrapper.peek(feature => { + $("#selectedFeatureGeoJson").text(feature.geojson()) + $("#selectedFeatureId").text(feature.id()) + $("#selectionPanel").show() + }) + }) }) + +$(document).ready(function() { + // Toggle the expanded/collapsed state of the panels when clicked + $(".panel").click(function() { + $(this).toggleClass("expanded"); + if ($(this).hasClass("expanded")) { + $(this).find("pre").slideDown(); + } else { + $(this).find("pre").slideUp(); + } + }); +}); diff --git a/static/mapcomponent/batch.js b/static/mapcomponent/featurelayer.js similarity index 55% rename from static/mapcomponent/batch.js rename to static/mapcomponent/featurelayer.js index 1cb73254..eb3e0c1c 100644 --- a/static/mapcomponent/batch.js +++ b/static/mapcomponent/featurelayer.js @@ -16,8 +16,13 @@ function blobUriFromWasm(coreLib, fun, contentType) { return glbUrl; } -/// Used to create and manage the visualization of one visual batch -export class MapViewerBatch +/** + * Bundle of a WASM TileFeatureLayer and a rendered representation + * in the form of a Cesium 3D TileSet which references a binary GLTF tile. + * The tileset JSON and the GLTF blob are stored as browser Blob objects + * (see https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static). + */ +export class FeatureLayerTileSet { // public: constructor(batchName, tileFeatureLayer) @@ -25,6 +30,9 @@ export class MapViewerBatch this.id = batchName; this.children = undefined; this.tileFeatureLayer = tileFeatureLayer; + this.glbUrl = null; + this.tileSetUrl = null; + this.tileSet = null; } /** @@ -43,7 +51,9 @@ export class MapViewerBatch glbConverter.makeTileset(this.glbUrl, origin, sharedBuffer); }, "application/json"); - Cesium.Cesium3DTileset.fromUrl(this.tileSetUrl).then(tileSet => { + Cesium.Cesium3DTileset.fromUrl(this.tileSetUrl, { + featureIdLabel: "mapgetFeatureIndex" + }).then(tileSet => { this.tileSet = tileSet; onResult(this); }); @@ -66,5 +76,34 @@ export class MapViewerBatch { this.disposeRenderResult(); this.tileFeatureLayer.delete(); + this.tileFeatureLayer = null; + } +} + +/** + * Wrapper which combines a FeatureLayerTileSet and the index of + * a feature within the tileset. Using the peek-function, it is + * possible to access the WASM feature view in a memory-safe way. + */ +export class FeatureWrapper +{ + constructor(index, featureLayerTileSet) { + this.index = index; + this.featureLayerTileSet = featureLayerTileSet; + } + + /** + * Run a callback with the WASM Feature object referenced by this wrapper. + * The feature object will be deleted after the callback is called. + */ + peek(callback) { + if (!this.featureLayerTileSet.tileFeatureLayer) { + throw new RuntimeError("Unable to access feature of deleted layer."); + } + let feature = this.featureLayerTileSet.tileFeatureLayer.at(this.index); + if (callback) { + callback(feature); + } + feature.delete(); } } diff --git a/static/mapcomponent/fetch.js b/static/mapcomponent/fetch.js index 9905be00..30e701e1 100644 --- a/static/mapcomponent/fetch.js +++ b/static/mapcomponent/fetch.js @@ -2,7 +2,8 @@ * A class to fetch data from a URL and process the response * for usage in JavaScript and WebAssembly. */ -export class Fetch { +export class Fetch +{ /** * Constructor to initialize the fetch processor with the required parameters. * @param {object} coreLib - The WebAssembly core library. diff --git a/static/mapcomponent/model.js b/static/mapcomponent/model.js index f347be0f..b1194fd7 100644 --- a/static/mapcomponent/model.js +++ b/static/mapcomponent/model.js @@ -2,7 +2,7 @@ import {throttle} from "./utils.js"; import {Fetch} from "./fetch.js"; -import {MapViewerBatch} from "./batch.js"; +import {FeatureLayerTileSet} from "./featurelayer.js"; const minViewportChangedCallDelta = 200; // ms @@ -46,11 +46,11 @@ export class MapViewerModel /// Triggered upon GLB load finished, with the visual and picking geometry batch roots. /// Received by frontend and MapViewerRenderingController. - this.batchAddedTopic = new rxjs.Subject(); // {batch} + this.batchAddedTopic = new rxjs.Subject(); // {MapViewerBatch} /// Triggered upon onBatchRemoved with the visual and picking geometry batch roots. /// Received by frontend and MapViewerRenderingController. - this.batchRemovedTopic = new rxjs.Subject(); // {batch} + this.batchRemovedTopic = new rxjs.Subject(); // {MapViewerBatch} /////////////////////////////////////////////////////////////////////////// // BOOTSTRAP // @@ -125,7 +125,7 @@ export class MapViewerModel addBatch(tile) { let batchName = tile.id(); - let batch = new MapViewerBatch(batchName, tile) + let batch = new FeatureLayerTileSet(batchName, tile) this.registeredBatches.set(batchName, batch) this.renderBatch(batch); } diff --git a/static/mapcomponent/view.js b/static/mapcomponent/view.js index 3644c1af..3ab09b5b 100644 --- a/static/mapcomponent/view.js +++ b/static/mapcomponent/view.js @@ -1,4 +1,5 @@ import {MapViewerModel} from "./model.js"; +import {FeatureWrapper} from "./featurelayer.js"; export class MapViewerView { @@ -9,15 +10,17 @@ export class MapViewerView */ constructor(model, containerDomElementId) { - // The base64 encoding of a 1x1 black PNG - let blackPixelBase64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + // The base64 encoding of a 1x1 black PNG. + let blackPixelBase64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'; this.viewer = new Cesium.Viewer(containerDomElementId, { - // Create a SingleTileImageryProvider that uses the black pixel + // Create a SingleTileImageryProvider that uses the black pixel. imageryProvider: new Cesium.SingleTileImageryProvider({ url: blackPixelBase64, - rectangle: Cesium.Rectangle.MAX_VALUE + rectangle: Cesium.Rectangle.MAX_VALUE, + tileWidth: 1, + tileHeight: 1 }), baseLayerPicker: false, animation: false, @@ -27,23 +30,82 @@ export class MapViewerView selectionIndicator: false, timeline: false, navigationHelpButton: false, - navigationInstructionsInitiallyVisible: false + navigationInstructionsInitiallyVisible: false, + requestRenderMode: true, + maximumRenderTimeChange: Infinity, + infoBox: false } ); - let openStreetMap = new Cesium.UrlTemplateImageryProvider({ - url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', - maximumLevel: 19, - }); - let openStreetMapLayer = this.viewer.imageryLayers.addImageryProvider(openStreetMap); - openStreetMapLayer.alpha = 0.5; + // let openStreetMap = new Cesium.UrlTemplateImageryProvider({ + // url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', + // maximumLevel: 19, + // }); + // let openStreetMapLayer = this.viewer.imageryLayers.addImageryProvider(openStreetMap); + // openStreetMapLayer.alpha = 0.5; + + this.pickedFeature = null; + this.hoveredFeature = null; + this.mouseHandler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas); + + /// Holds the currently selected feature. + this.selectionTopic = new rxjs.BehaviorSubject(null); // {FeatureWrapper} + + // Add a handler for selection. + this.mouseHandler.setInputAction(movement => { + // If there was a previously picked feature, reset its color. + if (this.pickedFeature) { + this.pickedFeature.color = Cesium.Color.WHITE; // Assuming the original color is WHITE. Adjust as necessary. + } + + let feature = this.viewer.scene.pick(movement.position); + + if (feature instanceof Cesium.Cesium3DTileFeature) { + feature.color = Cesium.Color.YELLOW; + this.pickedFeature = feature; // Store the picked feature. + this.hoveredFeature = null; + this.selectionTopic.next(this.resolveFeature(feature.tileset, feature.featureId)) + } + else + this.selectionTopic.next(null); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + + // Add a handler for hover (i.e., MOUSE_MOVE) functionality. + this.mouseHandler.setInputAction(movement => { + // If there was a previously hovered feature, reset its color. + if (this.hoveredFeature) { + this.hoveredFeature.color = Cesium.Color.WHITE; // Assuming the original color is WHITE. Adjust as necessary. + } + + let feature = this.viewer.scene.pick(movement.endPosition); // Notice that for MOUSE_MOVE, it's endPosition + + if (feature instanceof Cesium.Cesium3DTileFeature) { + if (feature !== this.pickedFeature) { + feature.color = Cesium.Color.GREEN; + this.hoveredFeature = feature; // Store the hovered feature. + } + } + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + + this.batchForTileSet = new Map(); model.batchAddedTopic.subscribe(batch => { this.viewer.scene.primitives.add(batch.tileSet); + this.batchForTileSet.set(batch.tileSet, batch); }) model.batchRemovedTopic.subscribe(batch => { this.viewer.scene.primitives.remove(batch.tileSet); + this.batchForTileSet.delete(batch.tileSet); }) } + + resolveFeature(tileSet, index) { + let batch = this.batchForTileSet.get(tileSet); + if (!batch) { + console.error("Failed find batch for tileSet!"); + return null; + } + return new FeatureWrapper(index, batch); + } }