Using IFeatureModel

To give access to feature data, you must useuseuse an feature modelfeature modelfeature model. IFeatureModelIFeatureModelIFeatureModel acts as an adapter between your application data and the LuciadCPillar Map.

As an adapter to your application data, IFeatureModelIFeatureModelIFeatureModel serves many purposes:

This article provides more details on these topics.

Working with Feature data

Application data can come in many different forms. Examples are:

  • A list of in-memory business objects

  • A database containing business objects

  • A file containing feature data, such as a SHP file or a GeoPackage file

  • A network stream with feature updates

To handle any kind of business data, and focus on the information needed for rendering or editing purposes, the LuciadCPillar API offers IFeatureModelIFeatureModelIFeatureModel. IFeatureModelIFeatureModelIFeatureModel is responsible for converting business data of any form to FeatureFeatureFeature instances, which LuciadCPillar can use.

IFeatureModelIFeatureModelIFeatureModel exposes FeatureFeatureFeature instances, which have a uniform structure and have a description of the data propertiesdata propertiesdata properties and geometriesgeometriesgeometries they contain. They’re a partial copy of the application data that’s of interest for the Map.

Uniquely identifying features

You can use feature IDs to identify features. Feature IDs are defined as unsigned 64-bit integers, and must be unique across the model. It’s important that no two features have the same ID. When this happens, LuciadCPillar considers them the same feature.

Adding properties to features

Features that have just an ID aren’t that interesting. Most likely, you want to associate some business data with each feature that you can then use during painting.

Each feature has an associated DataTypeDataTypeDataType that describes its property structure. During the creation of the feature, you can assignassignassign values to the various properties.

For more information on data types, see Describing a feature structure with a data model.

Features are read-only

You can’t modify Features once they have been created. The main advantage of making Feature instances read-only is that LuciadCPillar can use them on multiple threads in a thread-safe way. If you want to modify a Feature, you can do so by making a copy that has the same ID.

Query (pull) mechanism to access application data

When the Map wants to render feature data, it wants to retrieve only those features that are visible at a certain camera location or at a certain Map scale. The main mechanism for this is a pull mechanism: the Map’s rendering back end retrieves the wanted features through the IFeatureModel::queryIFeatureModel::queryIFeatureModel::query method.

This method allows you to use filters by configuring them on FeatureQueryFeatureQueryFeatureQuery. The Map uses these filters to get only the feature data it needs at a certain view point. For example:

How are feature queries used by the Map?

Imagine a feature data set with a large amount of roads data across the world: highways and smaller roads. You want to visualize this data in a scalable way.

  • At a zoomed-out level, you want to see only the main features in the dataset. Loading all small roads features for the entire world wouldn’t fit into memory or would take a huge amount of processing time to display them.

  • At a zoomed-in level, you want to see both highways and small roads, but only the ones that are visible in the region of the Map you’re looking at.

The Map accomplishes this by using feature queries with bounds and conditions on it.

  • At a zoomed-out level, a condition limits the number of returned features, even when the bounds on the FeatureQueryFeatureQueryFeatureQuery object cover a large part of the world.

  • At a zoomed-in level, the used condition allows returning more (or all) types of features, but the size of the bounds on the FeatureQueryFeatureQueryFeatureQuery object is small. The end result is again, a relatively small set of features.

See the visualizing feature data article for more information on how to configure the Map to display feature data in a scalable way.

Observer (push) mechanism to observe changes to the application data

Next to the pull mechanism, a pull mechanism may also be required.

If models don’t change, such as static roads data, you only need the pull mechanism. This changes when the data back end — wrapped by IFeatureModelIFeatureModelIFeatureModel — is dynamic in nature. Examples are:

  • Purely dynamic data: for example, the CustomerTrackModel that’s used in the Tracks sample. This model’s data back end is a stream of location updates for tracks.

  • Semi-dynamic data: the data is mostly static, but the user can also edit it. See the editing sample for an example of such a use case.

In both cases, the Map’s rendering back end still uses the pull mechanism to retrieve the data that it’s interested in. However, the detection of changes to the back end isn’t handled by IFeatureModel::queryIFeatureModel::queryIFeatureModel::query . This would require the Map to call the query function continuously, which would be highly inefficient.

To resolve this, IFeatureModelIFeatureModelIFeatureModel also offers an observer mechanism: IFeatureModelObserverIFeatureModelObserverIFeatureModelObserver. This mechanism allows the Map, or anyone else, to get notifications when the application data changes. These changes can be:

  • Updates: the feature location or data properties have changed, so the visualization needs an update.

  • Additions: new features were added, so they need to be added to the Map.

  • Removals: a feature was removed, so it needs to be removed from the Map.

IFeatureModelObserverIFeatureModelObserverIFeatureModelObserver is the core API that implements the push mechanism. This API is complementary with the queryqueryquery API. You must implement both in a correct and consistent way to render a model correctly.

One important note is that IFeatureModelObserverIFeatureModelObserverIFeatureModelObserver is decoupled from IFeatureModel::queryIFeatureModel::queryIFeatureModel::query: IFeatureModelObserverIFeatureModelObserverIFeatureModelObserver must transmit all data changes, not only the ones that are currently visualized by the Map, or that were previously returned by the query method. The Map rendering back end filters out the changes that it isn’t interested in.

Making changes to the application data

So far, this article only discussed feature information going from the application’s data back end to the Map, using both the query (pull) and observer (push) mechanisms. However, IFeatureModelIFeatureModelIFeatureModel also allows making changes to the application data, through the use of IFeatureModelUpdaterIFeatureModelUpdaterIFeatureModelUpdater.

You make changes to the data when editingeditingediting or creatingcreatingcreating features on the Map. Editing or creation generates new or modified FeatureFeatureFeature instances, that are then sent to IFeatureModelUpdaterIFeatureModelUpdaterIFeatureModelUpdater, which modifies the underlying application data based on these changes.

How does this tie in with the Map?

The Map’s rendering back end isn’t interested in IFeatureModelUpdaterIFeatureModelUpdaterIFeatureModelUpdater itself. It doesn’t use it. It’s only interested in calls to IFeatureModelObserverIFeatureModelObserverIFeatureModelObserver. The following happens when editing features on the Map:

  1. The editoreditoreditor makes a new version of a Feature, with a modified geometry for example, and calls IFeatureModelUpdaterIFeatureModelUpdaterIFeatureModelUpdater.

  2. IFeatureModelUpdaterIFeatureModelUpdaterIFeatureModelUpdater applies this change to the application data.

  3. The application data changed, so IFeatureModelIFeatureModelIFeatureModel must to notifynotifynotify that this happened.

  4. The Map’s rendering back end retrieves this notification and updates the visualization on the Map.

Persisting data

IFeatureModelIFeatureModelIFeatureModel gives you control over when to persist the edited data. For more information on saving changes to feature models, see Working with feature models with save support.

Choosing a suitable IFeatureModel

Which IFeatureModelIFeatureModelIFeatureModel you need depends on the data back end you are using, and the static or dynamic nature of the data. This section goes over a few cases and gives advice on the type of model to use.

Decode feature data from files

You can decode feature data from file formats such as GeoPackage or SHP. Each supported vector format offers a model decoder in the API. That model decoder returns a IFeatureModelIFeatureModelIFeatureModel, based on a path to the data file.

A model based on a list of Feature instances

When you have a list of Feature instances, you can use FeatureModelBuilderFeatureModelBuilderFeatureModelBuilder. This is a convenience model implementation that’s easy to use and doesn’t require much customization. It isn’t suited for large amounts of features however: it doesn’t have a spatial index to efficiently filter on boundsboundsbounds and it doesn’t have other indices to efficiently filter based on conditionsconditionsconditions.

Only use this model when your data set is relatively small.

Connect to a database back end

You must implement your own IFeatureModelIFeatureModelIFeatureModel instance that can connect to the database you are using.

Adapt your own application data

You must implement your own IFeatureModelIFeatureModelIFeatureModel instance that can query the application data, send notifications for changes, and that converts the application data to FeatureFeatureFeature instances.

A (network) stream of data

You must implement your own IFeatureModelIFeatureModelIFeatureModel instance that can capture the data stream and convert it to FeatureFeatureFeature instances. Because a stream can’t be queried, you may find it challenging to implement the queryqueryquery method. To resolve this issue, you can build up a local cache of the streamed data so that you can query it efficiently.

How to implement an IFeatureModel

In some scenarios, you need your own IFeatureModelIFeatureModelIFeatureModel implementation. This section gives some guidance on how to create your own implementation.

Use FeatureModelBuilder if possible

Implementing an IFeatureModelIFeatureModelIFeatureModel correctly isn’t a trivial task. Before you continue, double-check if you can use an existing implementation, such as FeatureModelBuilderFeatureModelBuilderFeatureModelBuilder. If you can’t use it directly, it may still be possible to use an existing model as a part of your new IFeatureModelIFeatureModelIFeatureModel implementation.

Define the structure of Feature and its DataType

When you’re implementing or creating an IFeatureModelIFeatureModelIFeatureModel, the first step is defining what a FeatureFeatureFeature contains and how it’s structured. You do so by creating a DataModelDataModelDataModel and DataTypeDataTypeDataType. For most use cases, the DataTypeDataTypeDataType must at least contain:

  • A Feature ID: to uniquely identify the feature. Possible ways to implement feature ID generation are:

    • Using a counter: in-memory for example, or in the database you connect to

    • Using an existing ID like a UUID or a string, and converting it to a 64-bits integer using hashing. Make sure to prevent hash collisions though.

  • A GeometryGeometryGeometry

For an example, see the Tracks sample:

Program: Defining the structure of a feature
DataModel createCustomerTrackDataModel() {
  auto speedValueProperty = DataProperty::newBuilder().name("SpeedValue").valueType(DataType::getDoubleType()).build();
  auto speedUnitProperty = DataProperty::newBuilder().name("SpeedUnit").valueType(DataType::getStringType()).build();
  auto speedType = DataType::newBuilder().name("SpeedType").addProperty(speedValueProperty).addProperty(speedUnitProperty).build();

  auto callSignProperty = DataProperty::newBuilder().name("CallSign").valueType(DataType::getStringType()).build();
  auto locationProperty = DataProperty::newBuilder().name("Location").valueType(DataType::getGeometryType()).build();
  auto trajectoryProperty = DataProperty::newBuilder().name("Trajectory").valueType(DataType::getGeometryType()).build();
  auto azimuthProperty = DataProperty::newBuilder().name("CourseOverGround").valueType(DataType::getDoubleType()).build();
  auto groundSpeedProperty = DataProperty::newBuilder().name("GroundSpeed").valueType(speedType).build();
  auto trackType = DataType::newBuilder()
                       .name("TrackType")
                       .addProperty(callSignProperty)
                       .addProperty(locationProperty)
                       .addProperty(trajectoryProperty)
                       .addProperty(azimuthProperty)
                       .addProperty(groundSpeedProperty)
                       .addAnnotation(IDataAnnotationFactory::create([&](const DataType& dataType) {
                         auto path = DataPropertyPath::newBuilder().originType(dataType).property(locationProperty).build();
                         return std::make_shared<GeometryDataAnnotation>(path);
                       }))
                       .build();

  return DataModel::newBuilder().name("http://www.mydomain.com/datamodel/TrackModel").addDataType(speedType).addDataType(trackType).build();
}

DataModel TrackDataModel = createCustomerTrackDataModel();
DataType TrackType = TrackDataModel.findDataType("TrackType").value();
ModelMetadata TrackModelMetadata = ModelMetadata::newBuilder().title("Tracks").build();
DataPropertyPath CallSignPropertyPath = DataPropertyPath::newBuilder().originType(TrackType).property("CallSign").build();
DataPropertyPath LocationPropertyPath = DataPropertyPath::newBuilder().originType(TrackType).property("Location").build();
DataPropertyPath TrajectoryPropertyPath = DataPropertyPath::newBuilder().originType(TrackType).property("Trajectory").build();
DataPropertyPath AzimuthPropertyPath = DataPropertyPath::newBuilder().originType(TrackType).property("CourseOverGround").build();
DataPropertyPath GroundSpeedValuePropertyPath = DataPropertyPath::newBuilder().originType(TrackType).property("GroundSpeed").property("SpeedValue").build();
DataPropertyPath GroundSpeedUnitPropertyPath = DataPropertyPath::newBuilder().originType(TrackType).property("GroundSpeed").property("SpeedUnit").build();

Feature createFeature(CustomerTrack* track, const std::shared_ptr<CoordinateReference>& modelReference) {
  std::shared_ptr<Point> trackLocation = GeometryFactory::createPoint(modelReference, track->getLon(), track->getLat(), track->getHeight());
  std::shared_ptr<Geometry> trajectory = GeometryFactory::createPolyline(modelReference,
                                                                         {{track->getStartLon(), track->getStartLat()},
                                                                          {track->getEndLon(), track->getEndLat()}},
                                                                         LineInterpolationType::Geodesic);

  return Feature::newBuilder()
      .id(track->getId())
      .dataType(CustomerTrackModel::getTrackType())
      .value<std::string>(CallSignPropertyPath, track->getCallSign())
      .value<std::shared_ptr<Geometry>>(LocationPropertyPath, std::move(trackLocation))
      .value<std::shared_ptr<Geometry>>(TrajectoryPropertyPath, std::move(trajectory))
      .value<double>(AzimuthPropertyPath, track->getAzimuth().getDegrees())
      .value<double>(GroundSpeedValuePropertyPath, track->getGroundSpeed())
      .value<std::string>(GroundSpeedUnitPropertyPath, "m/s")
      .build();
}
private static DataModel CreateCustomerTrackDataModel()
{
    var dataModelBuilder = DataModel.NewBuilder().Name("http://www.mydomain.com/datamodel/TrackModel");

    var speedType = DataType.NewBuilder().Name("SpeedType")
        .AddProperty(DataProperty.NewBuilder().Name("SpeedValue")
            .ValueType(DataType.DoubleType)
            .Build())
        .AddProperty(DataProperty.NewBuilder().Name("SpeedUnit")
            .ValueType(DataType.StringType)
            .Build())
        .Build();

    dataModelBuilder.AddDataType(speedType);

    var locationProperty = DataProperty.NewBuilder().Name("Location")
        .ValueType(DataType.GeometryType)
        .Build();
    var trajectoryProperty = DataProperty.NewBuilder().Name("Trajectory")
        .ValueType(DataType.GeometryType)
        .Build();
    var azimuthProperty = DataProperty.NewBuilder().Name("CourseOverGround")
        .ValueType(DataType.DoubleType)
        .Build();
    var trackType = DataType.NewBuilder().Name("TrackType")
        .AddProperty(DataProperty.NewBuilder().Name("CallSign")
            .ValueType(DataType.StringType)
            .Build())
        .AddProperty(locationProperty)
        .AddProperty(trajectoryProperty)
        .AddProperty(azimuthProperty)
        .AddProperty(DataProperty.NewBuilder().Name("GroundSpeed")
            .ValueType(speedType)
            .Build())
        .AddAnnotation(new GeometryDataAnnotationFactory(locationProperty))
        .Build();

    dataModelBuilder.AddDataType(trackType);

    return dataModelBuilder.Build();
}
private static Feature CreateFeature(CustomerTrack track, CoordinateReference modelReference)
{
    Point trackLocation = GeometryFactory.CreatePoint(modelReference, track.Lon, track.Lat, track.Height);
    Polyline trajectory = GeometryFactory.CreatePolyline(modelReference,
        new List<Coordinate>() { new Coordinate(track.StartLon, track.StartLat), new Coordinate(track.EndLon, track.EndLat) }
        , LineInterpolationType.Geodesic);

    Feature.Builder featureBuilder = Feature.NewBuilder().Id(track.Id).DataType(TrackType);

    featureBuilder.Value<string>(CallSignPropertyPath, track.CallSign);
    featureBuilder.Value<Geometry>(LocationPropertyPath, trackLocation);
    featureBuilder.Value<Geometry>(TrajectoryPropertyPath, trajectory);
    featureBuilder.Value<double>(AzimuthPropertyPath, track.Azimuth.Degrees);
    featureBuilder.Value<double>(GroundSpeedValuePropertyPath, track.GroundSpeed);
    featureBuilder.Value<string>(GroundSpeedUnitPropertyPath, "m/s");

    return featureBuilder.Build();
}
private static DataModel createCustomerTrackDataModel() {
  var dataModelBuilder = DataModel.newBuilder().name("http://www.mydomain.com/datamodel/TrackModel");

  var speedType = DataType.newBuilder().name("SpeedType")
      .addProperty(DataProperty.newBuilder().name("SpeedValue")
          .valueType(DataType.getDoubleType())
          .build())
      .addProperty(DataProperty.newBuilder().name("SpeedUnit")
          .valueType(DataType.getStringType())
          .build())
      .build();

  dataModelBuilder.addDataType(speedType);

  var locationProperty = DataProperty.newBuilder().name("Location")
      .valueType(DataType.getGeometryType())
      .build();
  var azimuthProperty = DataProperty.newBuilder().name("CourseOverGround")
      .valueType(DataType.getDoubleType())
      .build();
  var trackType = DataType.newBuilder().name("TrackType")
      .addProperty(DataProperty.newBuilder().name("CallSign")
          .valueType(DataType.getStringType())
          .build())
      .addProperty(locationProperty)
      .addProperty(azimuthProperty)
      .addProperty(DataProperty.newBuilder().name("GroundSpeed")
          .valueType(speedType)
          .build())
      .addAnnotation(new GeometryDataAnnotationFactory(locationProperty))
      .build();

  dataModelBuilder.addDataType(trackType);

  return dataModelBuilder.build();
}
private static Feature createFeature(CustomerTrack track, CoordinateReference modelReference) {
  Point trackLocation = GeometryFactory.createPoint(modelReference, track.getLon(), track.getLat(), track.getHeight());

  Feature.Builder featureBuilder = Feature.newBuilder().id(track.getId()).dataType(_trackType);

  featureBuilder.value(_callSignPropertyPath, track.getCallSign());
  featureBuilder.<Geometry>value(_locationPropertyPath, trackLocation);
  featureBuilder.value(_azimuthPropertyPath, track.getAzimuth().getDegrees());
  featureBuilder.value(_groundSpeedValuePropertyPath, track.getGroundSpeed());
  featureBuilder.value(_groundSpeedUnitPropertyPath, "m/s");

  return featureBuilder.build();
}

Implement the query method

All IFeatureModelIFeatureModelIFeatureModel implementations must implement the queryqueryquery method. Such an implementation must take a couple of things into account.

General considerations

IFeatureQueryCallback

The IFeatureQueryCallbackIFeatureQueryCallbackIFeatureQueryCallback argument is used to return the result of the query. You must pass every FeatureFeatureFeature that the filters in FeatureQueryFeatureQueryFeatureQuery include to the IFeatureQueryCallback::handleFeatureIFeatureQueryCallback::handleFeatureIFeatureQueryCallback::handleFeature method.

In turn, IFeatureQueryCallback::handleFeature returns a boolean to indicate that the current query should stop. Implementations of the query method must take this into account. Not doing so may have an impact on feature loading performance.

Program: Calling IFeatureQueryCallback
for (const Feature& feature : featuresToReturn) {
  if (!callback.handleFeature(feature)) {
    break;
  }
}
foreach (var feature in features)
{
    if (!callback.HandleFeature(feature))
    {
        break;
    }
}
for (Feature feature : featuresToReturn) {
  if (!callback.handleFeature(feature)) {
    break;
  }
}
Threading

The implementation of queryqueryquery must be implemented in a synchronous and blocking way. When the query method returns, no more features should be passed to IFeatureQueryCallbackIFeatureQueryCallbackIFeatureQueryCallback.

The query method can be called from any thread, so implementations must be thread-safe.

Condition

All FeatureFeatureFeature instances that you return must be accepted by the query conditionquery conditionquery condition, if available. Ideally, your IFeatureModelIFeatureModelIFeatureModel uses some kind of indexing to make this as efficient as possible. This is possible in many databases, for example.

If there is no other way to filter on the given condition using the data back end itself, you can still loop over all features and evaluate the filter.

Program: Filtering features
bool evaluateCondition = query.getCondition() || query.getBounds();
auto conditionEvaluator = FeatureExpressionEvaluatorFactory::createEvaluator(query);
  for (auto& feature : features) {
    if (!evaluateCondition || FeatureExpressionEvaluator::accept(feature, conditionEvaluator)) {
      featuresToReturn.emplace_back(feature);

      if (limit && *limit == featuresToReturn.size()) {
        break;
      }
    }
  }
var conditionEvaluator = FeatureExpressionEvaluatorFactory.CreateEvaluator(query);
        features = features
            .Where(f => FeatureExpressionEvaluator.Accept(f, conditionEvaluator))
            .Take((int)limit.Value)
            .ToList();
var conditionEvaluator = FeatureExpressionEvaluatorFactory.createEvaluator(query);
  List<Feature> featuresToReturn = new ArrayList<>();
  for (Feature feature : features) {
    if (!FeatureExpressionEvaluator.accept(feature, conditionEvaluator)) {
      continue;
    }

    featuresToReturn.add(feature);
    if (limit != null && featuresToReturn.size() >= limit) {
      break;
    }
  }

For more detail on using expressions, see Filtering data with expressions and conditions.

Bounds filter

All FeatureFeatureFeature instances that you return must overlap with the query boundsquery boundsquery bounds, if available. Ideally, your IFeatureModelIFeatureModelIFeatureModel uses a spatial index to check this in an efficient way. You can:

  • Use the spatial index of the data back end if it exists, for example in a spatial database.

  • Implement a spatial index yourself.

  • Don’t use a spatial filter. Beware, for larger models this can have a severe performance impact.

Note that spatial filtering is also implemented when you’re using FeatureExpressionEvaluatorFactoryFeatureExpressionEvaluatorFactoryFeatureExpressionEvaluatorFactory, as in the previous subsection.

Order

You must order returned features according to the query sort operatorquery sort operatorquery sort operator. Ideally, the data back end supports that and takes care of it, like a database. If back end support isn’t available, you can sort the list of features to return as follows:

Program: Sorting features
std::optional<FeatureQuery::SortOperator> order = query.getSortBy();
  if (order) {
    std::sort(std::begin(features), std::end(features), order->getComparer());
  }
var order = query.SortBy;
    if (order != null)
    {
        features.Sort(new FeatureComparer(order));
    }
var order = query.getSortBy();
  if (order != null) {
    features.sort(new FeatureComparator(order));
  }

Feature IDs

If the query has a feature id filterfeature id filterfeature id filter, you must only return the features with an ID that’s in this list.

Adding support for observing changes to the data

You can add support for observing changes to the data by registering observers and calling them whenever a feature in the data back end changes.

Program: Observer support
void CustomerTrackModel::addObserver(std::shared_ptr<IFeatureModelObserver> modelObserver) {
  std::lock_guard<std::mutex> lockGuard(_observerMutex);
  auto it = std::find_if(_modelObservers.begin(), _modelObservers.end(),
                         [&](const std::shared_ptr<IFeatureModelObserver>& observer) { return observer.get() == modelObserver.get(); });
  if (it != _modelObservers.end()) {
    throw InvalidArgumentException("CustomerTrackModel::addObserver: it is not allowed to add the same observer twice");
  }
  _modelObservers.emplace_back(std::move(modelObserver));
}

void CustomerTrackModel::removeObserver(const std::shared_ptr<IFeatureModelObserver>& modelObserver) {
  std::lock_guard<std::mutex> lockGuard(_observerMutex);
  auto it = std::find_if(_modelObservers.begin(), _modelObservers.end(),
                         [&](const std::shared_ptr<IFeatureModelObserver>& observer) { return observer.get() == modelObserver.get(); });
  if (it != _modelObservers.end()) {
    _modelObservers.erase(it);
  } else {
    throw InvalidArgumentException("CustomerTrackModel::removeObserver: attempting to remove an observer that was never added");
  }
}
void CustomerTrackModel::fireChangeEvent(const FeatureModelEvent& changeEvent) {
  // Fire change events outside the lock to make the code less prone to deadlocks
  std::unique_lock<std::mutex> lockGuard(_observerMutex);
  auto observersCopy = _modelObservers;
  lockGuard.unlock();

  for (const auto& observer : observersCopy) {
    observer->onFeatureModelChanged(changeEvent);
  }
}
private readonly IList<IFeatureModelObserver> _modelObservers = new List<IFeatureModelObserver>();

private void FireChangeEvent(FeatureModelEvent changeEvent)
{
    lock (_modelObservers)
    {
        foreach (IFeatureModelObserver observer in _modelObservers)
        {
            observer.OnFeatureModelChanged(changeEvent);
        }
    }
}
public void AddObserver(IFeatureModelObserver modelObserver)
{
    lock (_modelObservers)
    {
        _modelObservers.Add(modelObserver);
    }
}

public void RemoveObserver(IFeatureModelObserver modelObserver)
{
    lock (_modelObservers)
    {
        if (!_modelObservers.Remove(modelObserver))
        {
            throw new ArgumentException("Attempted to remove model observer that wasn't added by the AddObserver method", "modelObserver");
        }
    }
}
private final List<IFeatureModelObserver> _modelObservers = new ArrayList<>();

private void fireChangeEvent(FeatureModelEvent changeEvent) {
  synchronized (_modelObservers) {
    for (IFeatureModelObserver observer : _modelObservers) {
      observer.onFeatureModelChanged(changeEvent);
    }
  }
}
@Override
public void addObserver(IFeatureModelObserver modelObserver) {
  synchronized (_modelObservers) {
    _modelObservers.add(modelObserver);
  }
}

@Override
public void removeObserver(IFeatureModelObserver modelObserver) {
  synchronized (_modelObservers) {
    if (!_modelObservers.remove(modelObserver)) {
      throw new IllegalArgumentException("Attempted to remove model observer that wasn't added by the AddObserver method");
    }
  }
}

Adding support for editing and creation

You can add support for editing and creation by implementing an IFeatureModelUpdaterIFeatureModelUpdaterIFeatureModelUpdater and returning an instance in the IFeatureModel::getUpdaterIFeatureModel::getUpdaterIFeatureModel::getUpdater method.

For more information on feature updates and saving changes to feature models, see Working with feature models with save support.