645 lines
20 KiB
JavaScript
645 lines
20 KiB
JavaScript
/**
|
|
* @module ol/render/webgl/MixedGeometryBatch
|
|
*/
|
|
import {inflateEnds} from '../../geom/flat/orient.js';
|
|
import RenderFeature from '../../render/Feature.js';
|
|
import {getUid} from '../../util.js';
|
|
|
|
/**
|
|
* @typedef {import("../../Feature.js").default} Feature
|
|
*/
|
|
/**
|
|
* @typedef {import("../../geom/Geometry.js").Type} GeometryType
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} GeometryBatchItem Object that holds a reference to a feature as well as the raw coordinates of its various geometries
|
|
* @property {Feature|RenderFeature} feature Feature
|
|
* @property {Array<Array<number>>} flatCoordss Array of flat coordinates arrays, one for each geometry related to the feature
|
|
* @property {number} [verticesCount] Only defined for linestring and polygon batches
|
|
* @property {number} [ringsCount] Only defined for polygon batches
|
|
* @property {Array<Array<number>>} [ringsVerticesCounts] Array of vertices counts in each ring for each geometry; only defined for polygons batches
|
|
* @property {number} [ref] The reference in the global batch (used for hit detection)
|
|
*/
|
|
|
|
/**
|
|
* @typedef {PointGeometryBatch|LineStringGeometryBatch|PolygonGeometryBatch} GeometryBatch
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} PolygonGeometryBatch A geometry batch specific to polygons
|
|
* @property {Object<string, GeometryBatchItem>} entries Dictionary of all entries in the batch with associated computed values.
|
|
* One entry corresponds to one feature. Key is feature uid.
|
|
* @property {number} geometriesCount Amount of geometries in the batch.
|
|
* @property {number} verticesCount Amount of vertices from geometries in the batch.
|
|
* @property {number} ringsCount How many outer and inner rings in this batch.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} LineStringGeometryBatch A geometry batch specific to lines
|
|
* @property {Object<string, GeometryBatchItem>} entries Dictionary of all entries in the batch with associated computed values.
|
|
* One entry corresponds to one feature. Key is feature uid.
|
|
* @property {number} geometriesCount Amount of geometries in the batch.
|
|
* @property {number} verticesCount Amount of vertices from geometries in the batch.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} PointGeometryBatch A geometry batch specific to points
|
|
* @property {Object<string, GeometryBatchItem>} entries Dictionary of all entries in the batch with associated computed values.
|
|
* One entry corresponds to one feature. Key is feature uid.
|
|
* @property {number} geometriesCount Amount of geometries in the batch.
|
|
*/
|
|
|
|
/**
|
|
* @classdesc This class is used to group several geometries of various types together for faster rendering.
|
|
* Three inner batches are maintained for polygons, lines and points. Each time a feature is added, changed or removed
|
|
* from the batch, these inner batches are modified accordingly in order to keep them up-to-date.
|
|
*
|
|
* A feature can be present in several inner batches, for example a polygon geometry will be present in the polygon batch
|
|
* and its linear rings will be present in the line batch. Multi geometries are also broken down into individual geometries
|
|
* and added to the corresponding batches in a recursive manner.
|
|
*
|
|
* Corresponding {@link module:ol/render/webgl/BatchRenderer} instances are then used to generate the render instructions
|
|
* and WebGL buffers (vertices and indices) for each inner batches; render instructions are stored on the inner batches,
|
|
* alongside the transform used to convert world coords to screen coords at the time these instructions were generated.
|
|
* The resulting WebGL buffers are stored on the batches as well.
|
|
*
|
|
* An important aspect of geometry batches is that there is no guarantee that render instructions and WebGL buffers
|
|
* are synchronized, i.e. render instructions can describe a new state while WebGL buffers might not have been written yet.
|
|
* This is why two world-to-screen transforms are stored on each batch: one for the render instructions and one for
|
|
* the WebGL buffers.
|
|
*/
|
|
class MixedGeometryBatch {
|
|
constructor() {
|
|
/**
|
|
* @private
|
|
*/
|
|
this.globalCounter_ = 0;
|
|
|
|
/**
|
|
* Refs are used as keys for hit detection.
|
|
* @type {Map<number, Feature|RenderFeature>}
|
|
* @private
|
|
*/
|
|
this.refToFeature_ = new Map();
|
|
|
|
/**
|
|
* Features are split in "entries", which are individual geometries. We use the following map to share a single ref for all those entries.
|
|
* @type {Map<string, number>}
|
|
* @private
|
|
*/
|
|
this.uidToRef_ = new Map();
|
|
|
|
/**
|
|
* The precision in WebGL shaders is limited.
|
|
* To keep the refs as small as possible we maintain an array of freed up references.
|
|
* @type {Array<number>}
|
|
* @private
|
|
*/
|
|
this.freeGlobalRef_ = [];
|
|
|
|
/**
|
|
* @type {PolygonGeometryBatch}
|
|
*/
|
|
this.polygonBatch = {
|
|
entries: {},
|
|
geometriesCount: 0,
|
|
verticesCount: 0,
|
|
ringsCount: 0,
|
|
};
|
|
|
|
/**
|
|
* @type {PointGeometryBatch}
|
|
*/
|
|
this.pointBatch = {
|
|
entries: {},
|
|
geometriesCount: 0,
|
|
};
|
|
|
|
/**
|
|
* @type {LineStringGeometryBatch}
|
|
*/
|
|
this.lineStringBatch = {
|
|
entries: {},
|
|
geometriesCount: 0,
|
|
verticesCount: 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Array<Feature|RenderFeature>} features Array of features to add to the batch
|
|
* @param {import("../../proj.js").TransformFunction} [projectionTransform] Projection transform.
|
|
*/
|
|
addFeatures(features, projectionTransform) {
|
|
for (let i = 0; i < features.length; i++) {
|
|
this.addFeature(features[i], projectionTransform);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Feature|RenderFeature} feature Feature to add to the batch
|
|
* @param {import("../../proj.js").TransformFunction} [projectionTransform] Projection transform.
|
|
*/
|
|
addFeature(feature, projectionTransform) {
|
|
let geometry = feature.getGeometry();
|
|
if (!geometry) {
|
|
return;
|
|
}
|
|
if (projectionTransform) {
|
|
geometry = geometry.clone();
|
|
geometry.applyTransform(projectionTransform);
|
|
}
|
|
this.addGeometry_(geometry, feature);
|
|
}
|
|
|
|
/**
|
|
* @param {Feature|RenderFeature} feature Feature
|
|
* @return {GeometryBatchItem|void} the cleared entry
|
|
* @private
|
|
*/
|
|
clearFeatureEntryInPointBatch_(feature) {
|
|
const featureUid = getUid(feature);
|
|
const entry = this.pointBatch.entries[featureUid];
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
this.pointBatch.geometriesCount -= entry.flatCoordss.length;
|
|
delete this.pointBatch.entries[featureUid];
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* @param {Feature|RenderFeature} feature Feature
|
|
* @return {GeometryBatchItem|void} the cleared entry
|
|
* @private
|
|
*/
|
|
clearFeatureEntryInLineStringBatch_(feature) {
|
|
const featureUid = getUid(feature);
|
|
const entry = this.lineStringBatch.entries[featureUid];
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
this.lineStringBatch.verticesCount -= entry.verticesCount;
|
|
this.lineStringBatch.geometriesCount -= entry.flatCoordss.length;
|
|
delete this.lineStringBatch.entries[featureUid];
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* @param {Feature|RenderFeature} feature Feature
|
|
* @return {GeometryBatchItem|void} the cleared entry
|
|
* @private
|
|
*/
|
|
clearFeatureEntryInPolygonBatch_(feature) {
|
|
const featureUid = getUid(feature);
|
|
const entry = this.polygonBatch.entries[featureUid];
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
this.polygonBatch.verticesCount -= entry.verticesCount;
|
|
this.polygonBatch.ringsCount -= entry.ringsCount;
|
|
this.polygonBatch.geometriesCount -= entry.flatCoordss.length;
|
|
delete this.polygonBatch.entries[featureUid];
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* @param {import("../../geom.js").Geometry|RenderFeature} geometry Geometry
|
|
* @param {Feature|RenderFeature} feature Feature
|
|
* @private
|
|
*/
|
|
addGeometry_(geometry, feature) {
|
|
const type = geometry.getType();
|
|
switch (type) {
|
|
case 'GeometryCollection': {
|
|
const geometries =
|
|
/** @type {import("../../geom.js").GeometryCollection} */ (
|
|
geometry
|
|
).getGeometriesArray();
|
|
for (const geometry of geometries) {
|
|
this.addGeometry_(geometry, feature);
|
|
}
|
|
break;
|
|
}
|
|
case 'MultiPolygon': {
|
|
const multiPolygonGeom =
|
|
/** @type {import("../../geom.js").MultiPolygon} */ (geometry);
|
|
this.addCoordinates_(
|
|
type,
|
|
multiPolygonGeom.getFlatCoordinates(),
|
|
multiPolygonGeom.getEndss(),
|
|
feature,
|
|
getUid(feature),
|
|
multiPolygonGeom.getStride(),
|
|
);
|
|
break;
|
|
}
|
|
case 'MultiLineString': {
|
|
const multiLineGeom =
|
|
/** @type {import("../../geom.js").MultiLineString|RenderFeature} */ (
|
|
geometry
|
|
);
|
|
this.addCoordinates_(
|
|
type,
|
|
multiLineGeom.getFlatCoordinates(),
|
|
multiLineGeom.getEnds(),
|
|
feature,
|
|
getUid(feature),
|
|
multiLineGeom.getStride(),
|
|
);
|
|
break;
|
|
}
|
|
case 'MultiPoint': {
|
|
const multiPointGeom =
|
|
/** @type {import("../../geom.js").MultiPoint|RenderFeature} */ (
|
|
geometry
|
|
);
|
|
this.addCoordinates_(
|
|
type,
|
|
multiPointGeom.getFlatCoordinates(),
|
|
null,
|
|
feature,
|
|
getUid(feature),
|
|
multiPointGeom.getStride(),
|
|
);
|
|
break;
|
|
}
|
|
case 'Polygon': {
|
|
const polygonGeom =
|
|
/** @type {import("../../geom.js").Polygon|RenderFeature} */ (
|
|
geometry
|
|
);
|
|
this.addCoordinates_(
|
|
type,
|
|
polygonGeom.getFlatCoordinates(),
|
|
polygonGeom.getEnds(),
|
|
feature,
|
|
getUid(feature),
|
|
polygonGeom.getStride(),
|
|
);
|
|
break;
|
|
}
|
|
case 'Point': {
|
|
const pointGeom = /** @type {import("../../geom.js").Point} */ (
|
|
geometry
|
|
);
|
|
this.addCoordinates_(
|
|
type,
|
|
pointGeom.getFlatCoordinates(),
|
|
null,
|
|
feature,
|
|
getUid(feature),
|
|
pointGeom.getStride(),
|
|
);
|
|
break;
|
|
}
|
|
case 'LineString':
|
|
case 'LinearRing': {
|
|
const lineGeom = /** @type {import("../../geom.js").LineString} */ (
|
|
geometry
|
|
);
|
|
|
|
const stride = lineGeom.getStride();
|
|
|
|
this.addCoordinates_(
|
|
type,
|
|
lineGeom.getFlatCoordinates(),
|
|
null,
|
|
feature,
|
|
getUid(feature),
|
|
stride,
|
|
lineGeom.getLayout?.(),
|
|
);
|
|
break;
|
|
}
|
|
default:
|
|
// pass
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {GeometryType} type Geometry type
|
|
* @param {Array<number>} flatCoords Flat coordinates
|
|
* @param {Array<number> | Array<Array<number>> | null} ends Coordinate ends
|
|
* @param {Feature|RenderFeature} feature Feature
|
|
* @param {string} featureUid Feature uid
|
|
* @param {number} stride Stride
|
|
* @param {import('../../geom/Geometry.js').GeometryLayout} [layout] Layout
|
|
* @private
|
|
*/
|
|
addCoordinates_(type, flatCoords, ends, feature, featureUid, stride, layout) {
|
|
/** @type {number} */
|
|
let verticesCount;
|
|
switch (type) {
|
|
case 'MultiPolygon': {
|
|
const multiPolygonEndss = /** @type {Array<Array<number>>} */ (ends);
|
|
for (let i = 0, ii = multiPolygonEndss.length; i < ii; i++) {
|
|
let polygonEnds = multiPolygonEndss[i];
|
|
const prevPolygonEnds = i > 0 ? multiPolygonEndss[i - 1] : null;
|
|
const startIndex = prevPolygonEnds
|
|
? prevPolygonEnds[prevPolygonEnds.length - 1]
|
|
: 0;
|
|
const endIndex = polygonEnds[polygonEnds.length - 1];
|
|
polygonEnds =
|
|
startIndex > 0
|
|
? polygonEnds.map((end) => end - startIndex)
|
|
: polygonEnds;
|
|
this.addCoordinates_(
|
|
'Polygon',
|
|
flatCoords.slice(startIndex, endIndex),
|
|
polygonEnds,
|
|
feature,
|
|
featureUid,
|
|
stride,
|
|
layout,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case 'MultiLineString': {
|
|
const multiLineEnds = /** @type {Array<number>} */ (ends);
|
|
for (let i = 0, ii = multiLineEnds.length; i < ii; i++) {
|
|
const startIndex = i > 0 ? multiLineEnds[i - 1] : 0;
|
|
this.addCoordinates_(
|
|
'LineString',
|
|
flatCoords.slice(startIndex, multiLineEnds[i]),
|
|
null,
|
|
feature,
|
|
featureUid,
|
|
stride,
|
|
layout,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case 'MultiPoint':
|
|
for (let i = 0, ii = flatCoords.length; i < ii; i += stride) {
|
|
this.addCoordinates_(
|
|
'Point',
|
|
flatCoords.slice(i, i + 2),
|
|
null,
|
|
feature,
|
|
featureUid,
|
|
null,
|
|
null,
|
|
);
|
|
}
|
|
break;
|
|
case 'Polygon': {
|
|
const polygonEnds = /** @type {Array<number>} */ (ends);
|
|
if (feature instanceof RenderFeature) {
|
|
const multiPolygonEnds = inflateEnds(flatCoords, polygonEnds);
|
|
if (multiPolygonEnds.length > 1) {
|
|
this.addCoordinates_(
|
|
'MultiPolygon',
|
|
flatCoords,
|
|
multiPolygonEnds,
|
|
feature,
|
|
featureUid,
|
|
stride,
|
|
layout,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
if (!this.polygonBatch.entries[featureUid]) {
|
|
this.polygonBatch.entries[featureUid] = this.addRefToEntry_(
|
|
featureUid,
|
|
{
|
|
feature: feature,
|
|
flatCoordss: [],
|
|
verticesCount: 0,
|
|
ringsCount: 0,
|
|
ringsVerticesCounts: [],
|
|
},
|
|
);
|
|
}
|
|
verticesCount = flatCoords.length / stride;
|
|
const ringsCount = ends.length;
|
|
const ringsVerticesCount = ends.map((end, ind, arr) =>
|
|
ind > 0 ? (end - arr[ind - 1]) / stride : end / stride,
|
|
);
|
|
this.polygonBatch.verticesCount += verticesCount;
|
|
this.polygonBatch.ringsCount += ringsCount;
|
|
this.polygonBatch.geometriesCount++;
|
|
this.polygonBatch.entries[featureUid].flatCoordss.push(
|
|
getFlatCoordinatesXY(flatCoords, stride),
|
|
);
|
|
this.polygonBatch.entries[featureUid].ringsVerticesCounts.push(
|
|
ringsVerticesCount,
|
|
);
|
|
this.polygonBatch.entries[featureUid].verticesCount += verticesCount;
|
|
this.polygonBatch.entries[featureUid].ringsCount += ringsCount;
|
|
for (let i = 0, ii = polygonEnds.length; i < ii; i++) {
|
|
const startIndex = i > 0 ? polygonEnds[i - 1] : 0;
|
|
this.addCoordinates_(
|
|
'LinearRing',
|
|
flatCoords.slice(startIndex, polygonEnds[i]),
|
|
null,
|
|
feature,
|
|
featureUid,
|
|
stride,
|
|
layout,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case 'Point':
|
|
if (!this.pointBatch.entries[featureUid]) {
|
|
this.pointBatch.entries[featureUid] = this.addRefToEntry_(
|
|
featureUid,
|
|
{
|
|
feature: feature,
|
|
flatCoordss: [],
|
|
},
|
|
);
|
|
}
|
|
this.pointBatch.geometriesCount++;
|
|
this.pointBatch.entries[featureUid].flatCoordss.push(flatCoords);
|
|
break;
|
|
case 'LineString':
|
|
case 'LinearRing':
|
|
if (!this.lineStringBatch.entries[featureUid]) {
|
|
this.lineStringBatch.entries[featureUid] = this.addRefToEntry_(
|
|
featureUid,
|
|
{
|
|
feature: feature,
|
|
flatCoordss: [],
|
|
verticesCount: 0,
|
|
},
|
|
);
|
|
}
|
|
verticesCount = flatCoords.length / stride;
|
|
this.lineStringBatch.verticesCount += verticesCount;
|
|
this.lineStringBatch.geometriesCount++;
|
|
this.lineStringBatch.entries[featureUid].flatCoordss.push(
|
|
getFlatCoordinatesXYM(flatCoords, stride, layout),
|
|
);
|
|
this.lineStringBatch.entries[featureUid].verticesCount += verticesCount;
|
|
break;
|
|
default:
|
|
// pass
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} featureUid Feature uid
|
|
* @param {GeometryBatchItem} entry The entry to add
|
|
* @return {GeometryBatchItem} the added entry
|
|
* @private
|
|
*/
|
|
addRefToEntry_(featureUid, entry) {
|
|
const currentRef = this.uidToRef_.get(featureUid);
|
|
|
|
// the ref starts at 1 to distinguish from white color (no feature)
|
|
const ref =
|
|
currentRef || this.freeGlobalRef_.pop() || ++this.globalCounter_;
|
|
entry.ref = ref;
|
|
if (!currentRef) {
|
|
this.refToFeature_.set(ref, entry.feature);
|
|
this.uidToRef_.set(featureUid, ref);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* Return a ref to the pool of available refs.
|
|
* @param {number} ref the ref to return
|
|
* @param {string} featureUid the feature uid
|
|
* @private
|
|
*/
|
|
removeRef_(ref, featureUid) {
|
|
if (!ref) {
|
|
throw new Error('This feature has no ref: ' + featureUid);
|
|
}
|
|
this.refToFeature_.delete(ref);
|
|
this.uidToRef_.delete(featureUid);
|
|
this.freeGlobalRef_.push(ref);
|
|
}
|
|
|
|
/**
|
|
* @param {Feature|RenderFeature} feature Feature
|
|
* @param {import("../../proj.js").TransformFunction} [projectionTransform] Projection transform.
|
|
*/
|
|
changeFeature(feature, projectionTransform) {
|
|
// the feature is not present in the batch; do not add it to avoid unexpected behaviors
|
|
if (!this.uidToRef_.get(getUid(feature))) {
|
|
return;
|
|
}
|
|
this.removeFeature(feature);
|
|
let geometry = feature.getGeometry();
|
|
if (!geometry) {
|
|
return;
|
|
}
|
|
if (projectionTransform) {
|
|
geometry = geometry.clone();
|
|
geometry.applyTransform(projectionTransform);
|
|
}
|
|
this.addGeometry_(geometry, feature);
|
|
}
|
|
|
|
/**
|
|
* @param {Feature|RenderFeature} feature Feature
|
|
*/
|
|
removeFeature(feature) {
|
|
let entry = this.clearFeatureEntryInPointBatch_(feature);
|
|
entry = this.clearFeatureEntryInPolygonBatch_(feature) || entry;
|
|
entry = this.clearFeatureEntryInLineStringBatch_(feature) || entry;
|
|
if (entry) {
|
|
this.removeRef_(entry.ref, getUid(entry.feature));
|
|
}
|
|
}
|
|
|
|
clear() {
|
|
this.polygonBatch.entries = {};
|
|
this.polygonBatch.geometriesCount = 0;
|
|
this.polygonBatch.verticesCount = 0;
|
|
this.polygonBatch.ringsCount = 0;
|
|
this.lineStringBatch.entries = {};
|
|
this.lineStringBatch.geometriesCount = 0;
|
|
this.lineStringBatch.verticesCount = 0;
|
|
this.pointBatch.entries = {};
|
|
this.pointBatch.geometriesCount = 0;
|
|
this.globalCounter_ = 0;
|
|
this.freeGlobalRef_ = [];
|
|
this.refToFeature_.clear();
|
|
this.uidToRef_.clear();
|
|
}
|
|
|
|
/**
|
|
* Resolve the feature associated to a ref.
|
|
* @param {number} ref Hit detected ref
|
|
* @return {Feature|RenderFeature} feature
|
|
*/
|
|
getFeatureFromRef(ref) {
|
|
return this.refToFeature_.get(ref);
|
|
}
|
|
|
|
isEmpty() {
|
|
return this.globalCounter_ === 0;
|
|
}
|
|
|
|
/**
|
|
* Will return a new instance of this class that only contains the features
|
|
* for which the provided callback returned true
|
|
* @param {function((Feature|RenderFeature)): boolean} featureFilter Feature filter callback
|
|
* @return {MixedGeometryBatch} Filtered geometry batch
|
|
*/
|
|
filter(featureFilter) {
|
|
const filtered = new MixedGeometryBatch();
|
|
filtered.globalCounter_ = this.globalCounter_;
|
|
filtered.uidToRef_ = this.uidToRef_;
|
|
filtered.refToFeature_ = this.refToFeature_;
|
|
let empty = true;
|
|
for (const feature of this.refToFeature_.values()) {
|
|
if (featureFilter(feature)) {
|
|
filtered.addFeature(feature);
|
|
empty = false;
|
|
}
|
|
}
|
|
// no feature was added at all; simply return an empty batch for consistency downstream
|
|
if (empty) {
|
|
return new MixedGeometryBatch();
|
|
}
|
|
return filtered;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Array<number>} flatCoords Flat coords
|
|
* @param {number} stride Stride
|
|
* @return {Array<number>} Flat coords with only XY components
|
|
*/
|
|
function getFlatCoordinatesXY(flatCoords, stride) {
|
|
if (stride === 2) {
|
|
return flatCoords;
|
|
}
|
|
return flatCoords.filter((v, i) => i % stride < 2);
|
|
}
|
|
|
|
/**
|
|
* @param {Array<number>} flatCoords Flat coords
|
|
* @param {number} stride Stride
|
|
* @param {string} layout Layout
|
|
* @return {Array<number>} Flat coords with only XY components
|
|
*/
|
|
function getFlatCoordinatesXYM(flatCoords, stride, layout) {
|
|
if (stride === 3 && layout === 'XYM') {
|
|
return flatCoords;
|
|
}
|
|
// this is XYZM layout
|
|
if (stride === 4) {
|
|
return flatCoords.filter((v, i) => i % stride !== 2);
|
|
}
|
|
// this is XYZ layout
|
|
if (stride === 3) {
|
|
return flatCoords.map((v, i) => (i % stride !== 2 ? v : 0));
|
|
}
|
|
// this is XY layout
|
|
return new Array(flatCoords.length * 1.5)
|
|
.fill(0)
|
|
.map((v, i) => (i % 3 === 2 ? 0 : flatCoords[Math.round(i / 1.5)]));
|
|
}
|
|
|
|
export default MixedGeometryBatch;
|