diff --git a/docs/config.json b/docs/config.json index c6f99b474e..7837c876f0 100644 --- a/docs/config.json +++ b/docs/config.json @@ -86,7 +86,10 @@ "CameraCalibrationParser", "B3dmParser", "ShapefileParser", - "LASParser" + "LASParser", + "GTXParser", + "GDFParser", + "ISGParser" ], "Converter": [ diff --git a/src/Main.js b/src/Main.js index 96fdb84b4e..e428168881 100644 --- a/src/Main.js +++ b/src/Main.js @@ -87,6 +87,9 @@ export { default as KMLParser } from 'Parser/KMLParser'; export { default as CameraCalibrationParser } from 'Parser/CameraCalibrationParser'; export { default as ShapefileParser } from 'Parser/ShapefileParser'; export { default as LASParser } from 'Parser/LASParser'; +export { default as ISGParser } from 'Parser/ISGParser'; +export { default as GDFParser } from 'Parser/GDFParser'; +export { default as GTXParser } from 'Parser/GTXParser'; export { enableDracoLoader, glTFLoader, legacyGLTFLoader } from 'Parser/B3dmParser'; // 3D Tiles classes and extensions diff --git a/src/Parser/GDFParser.js b/src/Parser/GDFParser.js new file mode 100644 index 0000000000..4bf2c738c2 --- /dev/null +++ b/src/Parser/GDFParser.js @@ -0,0 +1,86 @@ +import * as THREE from 'three'; +import GeoidGrid from 'Core/Geographic/GeoidGrid'; +import Extent from 'Core/Geographic/Extent'; +import { BYTES_PER_DOUBLE } from 'Parser/GTXParser'; + + +export function getHeaderAttribute(header, attributeName) { + const attributeRow = header[header.indexOf(header.find(element => element.includes(attributeName)))].split(' ') + .filter(value => value !== ''); + return parseFloat(attributeRow[attributeRow.length - 1]); +} + + +/** + * The `GDFParser` module provides a `[parse]{@link module:GDFParser.parse}` method. This method takes the content of a + * GDF file in, and returns a `{@link GeoidGrid}`. the `{@link GeoidGrid}` contains all the necessary attributes and + * methods to access the GDF data in iTowns. + * + * @module GDFParser + */ +export default { + /** + * Parses a GDF file content and returns a corresponding `{@link GeoidGrid}`. + * + * @param {string} gdf The content of the GDF file to parse. + * @param {Object} options An object gathering the optional parameters to pass to + * the parser. + * @param {Object} [options.in={}] Information on the GDF data. + * @param {string} [options.in.crs='EPSG:4326'] The Coordinates Reference System (CRS) of the GDF data. + * It must be a geographic CRS, and must be given as an EPSG + * code. + * + * @returns {Promise} A promise resolving with a `{@link GeoidGrid}`, which contains all the necessary + * attributes and methods to access GDF file data. + */ + parse(gdf, options = { in: {} }) { + const rows = gdf.split('\n'); + const firstMeasureLine = rows.indexOf(rows.find(row => row.includes('end_of_head'))) + 1; + const rawHeaderData = rows.slice(0, firstMeasureLine); + + // ---------- GET METADATA FROM THE FILE : ---------- + + const metadata = { + minX: getHeaderAttribute(rawHeaderData, 'longlimit_west'), + maxX: getHeaderAttribute(rawHeaderData, 'longlimit_east'), + minY: getHeaderAttribute(rawHeaderData, 'latlimit_south'), + maxY: getHeaderAttribute(rawHeaderData, 'latlimit_north'), + stepX: getHeaderAttribute(rawHeaderData, 'gridstep'), + stepY: getHeaderAttribute(rawHeaderData, 'gridstep'), + nRows: getHeaderAttribute(rawHeaderData, 'latitude_parallels'), + nColumns: getHeaderAttribute(rawHeaderData, 'longitude_parallels'), + }; + + // ---------- BUILD A DATA VIEWER FROM THE TEXT DATA : ---------- + + const data = new DataView( + new ArrayBuffer(BYTES_PER_DOUBLE * metadata.nRows * metadata.nColumns), + ); + + let index = 0; + for (let row of rows.slice(firstMeasureLine, rows.length)) { + row = row.split(' ').filter(value => value !== ''); + + if (!row.length) { continue; } + + data.setFloat64(index * BYTES_PER_DOUBLE, parseFloat(row[2])); + index++; + } + + // ---------- CREATE A GeoidGrid FOR THE GIVEN FILE DATA : ---------- + + const dataExtent = new Extent( + options.in.crs || 'EPSG:4326', + metadata.minX, metadata.maxX, metadata.minY, metadata.maxY, + ); + + const dataStep = new THREE.Vector2(metadata.stepX, metadata.stepY); + + const getData = (verticalIndex, horizontalIndex) => + data.getFloat64(( + metadata.nColumns * (metadata.nRows - verticalIndex - 1) + horizontalIndex + ) * BYTES_PER_DOUBLE); + + return Promise.resolve(new GeoidGrid(dataExtent, dataStep, getData)); + }, +}; diff --git a/src/Parser/GTXParser.js b/src/Parser/GTXParser.js new file mode 100644 index 0000000000..3b78e81adb --- /dev/null +++ b/src/Parser/GTXParser.js @@ -0,0 +1,90 @@ +import * as THREE from 'three'; +import GeoidGrid from 'Core/Geographic/GeoidGrid'; +import Extent from 'Core/Geographic/Extent'; + + +export const BYTES_PER_DOUBLE = 8; +export const BYTES_PER_FLOAT = 4; + + +/** + * The `GTXParser` module provides a `[parse]{@link module:GTXParser.parse}` method. This method takes the content of a + * GTX file in, and returns a `{@link GeoidGrid}`. The `{@link GeoidGrid}` contains all the necessary attributes and + * methods to access the GTX data in iTowns. + * + * @module GTXParser + */ +export default { + /** + * Parses a GTX file content and returns a corresponding `{@link GeoidGrid}`. + * + * @param {ArrayBuffer} gtx The content of the GTX file to parse. + * @param {Object} options An object gathering the optional parameters to pass to + * the parser. + * @param {Object} [options.in={}] Information on the GTX data. + * @param {string} [options.in.crs='EPSG:4326'] The Coordinates Reference System (CRS) of the GTX data. + * It must be a geographic CRS, and must be given as an + * EPSG code. + * @param {string} [options.in.dataType='float'] The encoding of geoid height data within the GTX file. + * Must be `'float'` or `'double'`. + * + * @returns {Promise} A promise resolving with a `{@link GeoidGrid}`, which contains all the necessary + * attributes and methods to access GTX file data. + */ + parse(gtx, options = { in: {} }) { + const dataType = options.in.dataType || 'float'; + if (!['float', 'double'].includes(dataType)) { + throw new Error( + '`dataType` parameter is incorrect for GTXParser.parse method. ' + + 'This parameter must be either `double` or `float`.', + ); + } + + // ---------- GET METADATA FROM THE FILE : ---------- + + const headerView = new DataView(gtx, 0, 40); + const metadata = { + minX: headerView.getFloat64(8), + minY: headerView.getFloat64(0), + stepX: headerView.getFloat64(24), + stepY: headerView.getFloat64(16), + nColumns: headerView.getInt32(36), + nRows: headerView.getInt32(32), + }; + + // ---------- BUILD A DATA VIEWER : ---------- + + const dataView = new DataView(gtx, 40); + + // ---------- CREATE A GeoidGrid FOR THE GIVEN FILE DATA : ---------- + + // formula for the max longitude : maxLongitude = minLongitude + deltaLongitude * (nColumns - 1) + const maxX = metadata.minX + metadata.stepX * (metadata.nColumns - 1); + // formula for the max latitude : maxLatitude = minLatitude + deltaLatitude * (nRows - 1) + const maxY = metadata.minY + metadata.stepY * (metadata.nRows - 1); + + const dataExtent = new Extent( + options.in.crs || 'EPSG:4326', + metadata.minX, maxX, metadata.minY, maxY, + ); + + const dataStep = new THREE.Vector2(metadata.stepX, metadata.stepY); + + const getData = (verticalIndex, horizontalIndex) => { + // formula to get the index of a geoid height from a latitude and longitude indexes is : + // ``(nColumns * latIndex + lonIndex) * nBytes``, where nBytes stands for the number of bytes geoid + // height data are encoded on. + if (dataType === 'float') { + return dataView.getFloat32( + (metadata.nColumns * verticalIndex + horizontalIndex) * BYTES_PER_FLOAT, + ); + } else if (dataType === 'double') { + return dataView.getFloat64( + (metadata.nColumns * verticalIndex + horizontalIndex) * BYTES_PER_DOUBLE, + ); + } + }; + + return Promise.resolve(new GeoidGrid(dataExtent, dataStep, getData)); + }, +}; diff --git a/src/Parser/ISGParser.js b/src/Parser/ISGParser.js new file mode 100644 index 0000000000..f734b34832 --- /dev/null +++ b/src/Parser/ISGParser.js @@ -0,0 +1,81 @@ +import * as THREE from 'three'; +import GeoidGrid from 'Core/Geographic/GeoidGrid'; +import Extent from 'Core/Geographic/Extent'; +import { getHeaderAttribute } from 'Parser/GDFParser'; +import { BYTES_PER_DOUBLE } from 'Parser/GTXParser'; + + +/** + * The `ISGParser` module provides a `[parse]{@link module:ISGParser.parse}` method. This method takes the content of a + * ISG file in, and returns a `{@link GeoidGrid}`. the `{@link GeoidGrid}` contains all the necessary attributes and + * methods to access the ISG data in iTowns. + * + * @module ISGParser + */ +export default { + /** + * Parses an ISG file content and returns a corresponding `{@link GeoidGrid}`. + * + * @param {string} isg The content of the ISG file to parse. + * @param {Object} options An object gathering the optional parameters to pass to + * the parser. + * @param {Object} [options.in={}] Information on the ISG data. + * @param {string} [options.in.crs='EPSG:4326'] The Coordinates Reference System (CRS) of the ISG data. + * It must be a geographic CRS, and must be given as an EPSG + * code. + * + * @returns {Promise} A promise resolving with a `{@link GeoidGrid}`, which contains all the necessary + * attributes and methods to access ISG file data. + */ + parse(isg, options = { in: {} }) { + const rows = isg.split('\n'); + const firstMeasureLine = rows.indexOf(rows.find(row => row.includes('end_of_head'))) + 1; + const rawHeaderData = rows.slice(0, firstMeasureLine); + + // ---------- GET METADATA FROM THE FILE : ---------- + + const metadata = { + minX: getHeaderAttribute(rawHeaderData, 'lon min'), + maxX: getHeaderAttribute(rawHeaderData, 'lon max'), + minY: getHeaderAttribute(rawHeaderData, 'lat min'), + maxY: getHeaderAttribute(rawHeaderData, 'lat max'), + stepX: getHeaderAttribute(rawHeaderData, 'delta lon'), + stepY: getHeaderAttribute(rawHeaderData, 'delta lat'), + nRows: getHeaderAttribute(rawHeaderData, 'nrows'), + nColumns: getHeaderAttribute(rawHeaderData, 'ncols'), + }; + + // ---------- BUILD A DATA VIEWER FROM THE TEXT DATA : ---------- + + const data = new DataView( + new ArrayBuffer(BYTES_PER_DOUBLE * metadata.nRows * metadata.nColumns), + ); + + let index = 0; + for (let row of rows.slice(firstMeasureLine, rows.length)) { + row = row.split(' ').filter(value => value !== ''); + + if (!row.length) { continue; } + + for (const value of row) { + data.setFloat64(index * BYTES_PER_DOUBLE, parseFloat(value)); + index++; + } + } + + // ---------- CREATE A GeoidGrid FOR THE GIVEN FILE DATA : ---------- + + const dataExtent = new Extent( + options.in.crs || 'EPSG:4326', + metadata.minX + metadata.stepX / 2, metadata.maxX - metadata.stepX / 2, + metadata.minY + metadata.stepY / 2, metadata.maxY - metadata.stepY / 2, + ); + + const dataStep = new THREE.Vector2(metadata.stepX, metadata.stepY); + + const getData = (verticalIndex, horizontalIndex) => + data.getFloat64((metadata.nColumns * verticalIndex + horizontalIndex) * BYTES_PER_DOUBLE); + + return Promise.resolve(new GeoidGrid(dataExtent, dataStep, getData)); + }, +}; diff --git a/src/Source/Source.js b/src/Source/Source.js index f8728c8647..a78d38c3f0 100644 --- a/src/Source/Source.js +++ b/src/Source/Source.js @@ -1,7 +1,10 @@ import Extent from 'Core/Geographic/Extent'; import GeoJsonParser from 'Parser/GeoJsonParser'; import KMLParser from 'Parser/KMLParser'; +import GDFParser from 'Parser/GDFParser'; import GpxParser from 'Parser/GpxParser'; +import GTXParser from 'Parser/GTXParser'; +import ISGParser from 'Parser/ISGParser'; import VectorTileParser from 'Parser/VectorTileParser'; import Fetcher from 'Provider/Fetcher'; import Cache from 'Core/Scheduler/Cache'; @@ -13,6 +16,9 @@ export const supportedFetchers = new Map([ ['application/kml', Fetcher.xml], ['application/gpx', Fetcher.xml], ['application/x-protobuf;type=mapbox-vector', Fetcher.arrayBuffer], + ['application/gtx', Fetcher.arrayBuffer], + ['application/isg', Fetcher.text], + ['application/gdf', Fetcher.text], ]); export const supportedParsers = new Map([ @@ -21,6 +27,9 @@ export const supportedParsers = new Map([ ['application/kml', KMLParser.parse], ['application/gpx', GpxParser.parse], ['application/x-protobuf;type=mapbox-vector', VectorTileParser.parse], + ['application/gtx', GTXParser.parse], + ['application/isg', ISGParser.parse], + ['application/gdf', GDFParser.parse], ]); const noCache = { getByArray: () => {}, setByArray: a => a, clear: () => {} }; diff --git a/test/unit/gdf.js b/test/unit/gdf.js new file mode 100644 index 0000000000..2438bc2b1b --- /dev/null +++ b/test/unit/gdf.js @@ -0,0 +1,38 @@ +import assert from 'assert'; +import HttpsProxyAgent from 'https-proxy-agent'; +import Fetcher from 'Provider/Fetcher'; +import GDFParser from 'Parser/GDFParser'; + + +describe('GDFParser', function () { + let text; + + before(async () => { + const networkOptions = process.env.HTTPS_PROXY ? { agent: new HttpsProxyAgent(process.env.HTTPS_PROXY) } : {}; + text = await Fetcher.text( + 'https://raw.githubusercontent.com/iTowns/iTowns2-sample-data/master/altitude-conversion-grids/' + + 'EGM2008.gdf', + networkOptions, + ); + }); + + it('should default `options.in.crs` parameter to `EPSG:4326`', async function () { + const geoidGrid = await GDFParser.parse(text); + assert.strictEqual(geoidGrid.extent.crs, 'EPSG:4326'); + }); + + it('should parse text data into a GeoidGrid', async function () { + const geoidGrid = await GDFParser.parse(text, { in: { crs: 'EPSG:4326' } }); + assert.strictEqual(geoidGrid.extent.west, -180); + assert.strictEqual(geoidGrid.extent.east, 180); + assert.strictEqual(geoidGrid.extent.south, -90); + assert.strictEqual(geoidGrid.extent.north, 90); + assert.strictEqual(geoidGrid.step.x, 1); + assert.strictEqual(geoidGrid.step.y, 1); + }); + + it('should set a correct data reading method for `GeoidGrid`', async function () { + const geoidGrid = await GDFParser.parse(text, { in: { crs: 'EPSG:4326' } }); + assert.strictEqual(geoidGrid.getData(geoidGrid.dataSize.y - 2, 1), 13.813008707225); + }); +}); diff --git a/test/unit/gtx.js b/test/unit/gtx.js new file mode 100644 index 0000000000..67d24f30dd --- /dev/null +++ b/test/unit/gtx.js @@ -0,0 +1,67 @@ +import assert from 'assert'; +import HttpsProxyAgent from 'https-proxy-agent'; +import GTXParser from 'Parser/GTXParser'; +import Fetcher from 'Provider/Fetcher'; + + +describe('GTXParser', function () { + let buffer; + + before(async () => { + const networkOptions = process.env.HTTPS_PROXY ? { agent: new HttpsProxyAgent(process.env.HTTPS_PROXY) } : {}; + buffer = await Fetcher.arrayBuffer( + 'https://raw.githubusercontent.com/iTowns/iTowns2-sample-data/master/altitude-conversion-grids/' + + 'RAF20_float.gtx', + networkOptions, + ); + }); + + it('should throw error if dataType parameter is wrongly specified', async function () { + assert.throws( + () => GTXParser.parse(buffer, { in: { dataType: 'foo', crs: 'EPSG:4326' } }), + { + name: 'Error', + message: '`dataType` parameter is incorrect for GTXParser.parse method. This parameter must be ' + + 'either `double` or `float`.', + }, + ); + }); + + it('should default `dataType` property to `float`', async function () { + const geoidGrid = await GTXParser.parse(buffer, { in: { crs: 'EPSG:4326' } }); + const dataView = new DataView(buffer, 40); + assert.strictEqual( + geoidGrid.getData(1, 1), + dataView.getFloat32(1688), + ); + }); + + it('should default `options.in.crs` parameter to `EPSG:4326`', async function () { + const geoidGrid = await GTXParser.parse(buffer, { in: { dataType: 'float' } }); + assert.strictEqual(geoidGrid.extent.crs, 'EPSG:4326'); + }); + + it('should parse ArrayBuffer data into a GeoidGrid', async function () { + const geoidGrid = await GTXParser.parse(buffer, { in: { dataType: 'float', crs: 'EPSG:4326' } }); + assert.strictEqual(geoidGrid.extent.west, -5.5); + assert.strictEqual(geoidGrid.extent.east, 8.499999999985999); + assert.strictEqual(geoidGrid.extent.south, 42); + assert.strictEqual(geoidGrid.extent.north, 51.5); + assert.strictEqual(geoidGrid.step.x, 0.0333333333333); + assert.strictEqual(geoidGrid.step.y, 0.025); + }); + + it('should set GeoidGrid reading method according to `dataType` property', async function () { + const geoidGridFloat = await GTXParser.parse(buffer, { in: { dataType: 'float', crs: 'EPSG:4326' } }); + const geoidGridDouble = await GTXParser.parse(buffer, { in: { dataType: 'double', crs: 'EPSG:4326' } }); + const dataView = new DataView(buffer, 40); + assert.strictEqual( + geoidGridFloat.getData(1, 1), + dataView.getFloat32(1688), + ); + assert.strictEqual( + geoidGridDouble.getData(1, 1), + dataView.getFloat64(3376), + ); + }); +}); diff --git a/test/unit/isg.js b/test/unit/isg.js new file mode 100644 index 0000000000..1dd3a0bc50 --- /dev/null +++ b/test/unit/isg.js @@ -0,0 +1,38 @@ +import assert from 'assert'; +import HttpsProxyAgent from 'https-proxy-agent'; +import Fetcher from 'Provider/Fetcher'; +import ISGParser from 'Parser/ISGParser'; + + +describe('ISGParser', function () { + let text; + + before(async () => { + const networkOptions = process.env.HTTPS_PROXY ? { agent: new HttpsProxyAgent(process.env.HTTPS_PROXY) } : {}; + text = await Fetcher.text( + 'https://raw.githubusercontent.com/iTowns/iTowns2-sample-data/master/altitude-conversion-grids/' + + 'raf09.isg', + networkOptions, + ); + }); + + it('should default `options.in.crs` parameter to `EPSG:4326`', async function () { + const geoidGrid = await ISGParser.parse(text); + assert.strictEqual(geoidGrid.extent.crs, 'EPSG:4326'); + }); + + it('should parse text data into a GeoidGrid', async function () { + const geoidGrid = await ISGParser.parse(text, { in: { crs: 'EPSG:4326' } }); + assert.strictEqual(geoidGrid.extent.west, -5.50005); + assert.strictEqual(geoidGrid.extent.east, 8.50005); + assert.strictEqual(geoidGrid.extent.south, 42.0); + assert.strictEqual(geoidGrid.extent.north, 51.5); + assert.strictEqual(geoidGrid.step.x, 0.0333); + assert.strictEqual(geoidGrid.step.y, 0.025); + }); + + it('should set a correct data reading method for `GeoidGrid`', async function () { + const geoidGrid = await ISGParser.parse(text, { in: { crs: 'EPSG:4326' } }); + assert.strictEqual(geoidGrid.getData(1, 1), 53.47); + }); +});