├── .gitignore ├── .mocharc.yaml ├── docs ├── delft.png ├── img │ ├── city-3d.png │ ├── buildings-3d.png │ ├── city-3d-color.png │ ├── buildings-diagram.png │ └── buildings-triangles.png ├── zurich-lod2.png ├── CityGML_1_0_0_UML_diagrams.pdf ├── CityGML_2_0_0_UML_diagrams.pdf └── background.md ├── .editorconfig ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── src ├── citygml │ ├── Envelope.mjs │ ├── Document.mjs │ ├── SRSTranslator.test.mjs │ ├── CityObject │ │ └── Building.mjs │ ├── CityModel.mjs │ ├── SRSTranslator.mjs │ ├── CityObject.mjs │ └── CityNode.mjs ├── geometry │ ├── LinearRing.mjs │ ├── Triangle.mjs │ ├── BoundingBox.mjs │ ├── Tesselator.mjs │ ├── TriangleMesh.mjs │ └── TriangleMesh.test.mjs ├── 3dtiles │ ├── Material.mjs │ ├── Tileset.mjs │ ├── Batched3DModel.mjs │ ├── BatchTable.mjs │ ├── parseB3dm.mjs │ ├── BatchTable.test.mjs │ ├── createB3dm.mjs │ ├── Mesh.mjs │ └── createGltf.mjs └── Converter.mjs ├── bin └── citygml-to-3dtiles.mjs ├── package.json ├── test ├── cli.test.mjs └── data │ └── sig3d-genericattributes-citygml2.xml ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | -------------------------------------------------------------------------------- /.mocharc.yaml: -------------------------------------------------------------------------------- 1 | spec: '**/*.test.mjs' 2 | -------------------------------------------------------------------------------- /docs/delft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njam/citygml-to-3dtiles/HEAD/docs/delft.png -------------------------------------------------------------------------------- /docs/img/city-3d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njam/citygml-to-3dtiles/HEAD/docs/img/city-3d.png -------------------------------------------------------------------------------- /docs/zurich-lod2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njam/citygml-to-3dtiles/HEAD/docs/zurich-lod2.png -------------------------------------------------------------------------------- /docs/img/buildings-3d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njam/citygml-to-3dtiles/HEAD/docs/img/buildings-3d.png -------------------------------------------------------------------------------- /docs/img/city-3d-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njam/citygml-to-3dtiles/HEAD/docs/img/city-3d-color.png -------------------------------------------------------------------------------- /docs/img/buildings-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njam/citygml-to-3dtiles/HEAD/docs/img/buildings-diagram.png -------------------------------------------------------------------------------- /docs/img/buildings-triangles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njam/citygml-to-3dtiles/HEAD/docs/img/buildings-triangles.png -------------------------------------------------------------------------------- /docs/CityGML_1_0_0_UML_diagrams.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njam/citygml-to-3dtiles/HEAD/docs/CityGML_1_0_0_UML_diagrams.pdf -------------------------------------------------------------------------------- /docs/CityGML_2_0_0_UML_diagrams.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njam/citygml-to-3dtiles/HEAD/docs/CityGML_2_0_0_UML_diagrams.pdf -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | ij_javascript_space_before_method_parentheses = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: '20.x' 16 | registry-url: 'https://registry.npmjs.org' 17 | - run: npm ci 18 | - run: npm publish 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [ 20.x, 18.x, 16.x ] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: npm ci 23 | - run: npm test 24 | -------------------------------------------------------------------------------- /src/citygml/Envelope.mjs: -------------------------------------------------------------------------------- 1 | import BoundingBox from '../geometry/BoundingBox.mjs' 2 | 3 | class Envelope { 4 | 5 | /** 6 | * @param {CityNode} cityNode 7 | */ 8 | constructor (cityNode) { 9 | cityNode.assertLocalName('Envelope') 10 | this.cityNode = cityNode 11 | } 12 | 13 | /** 14 | * @returns {BoundingBox} 15 | */ 16 | getBoundingBox () { 17 | let lowerCorner = this.cityNode.selectCityNode('./gml:lowerCorner').getTextAsCoordinates1Cartographic() 18 | let upperCorner = this.cityNode.selectCityNode('./gml:upperCorner').getTextAsCoordinates1Cartographic() 19 | return new BoundingBox(lowerCorner, upperCorner) 20 | } 21 | 22 | } 23 | 24 | export default Envelope 25 | -------------------------------------------------------------------------------- /src/geometry/LinearRing.mjs: -------------------------------------------------------------------------------- 1 | import Triangle from './Triangle.mjs' 2 | import Tesselator from './Tesselator.mjs' 3 | 4 | class LinearRing { 5 | /** 6 | * @param {Cesium.Cartesian3[]} vertices 7 | */ 8 | constructor (vertices) { 9 | if (vertices.length < 4) { 10 | throw new Error('Invalid vertices length for LinearRing: ' + vertices.length) 11 | } 12 | this.vertices = vertices 13 | } 14 | 15 | /** 16 | * @returns {Cesium.Cartesian3[]} 17 | */ 18 | getVertices () { 19 | return this.vertices 20 | } 21 | 22 | /** 23 | * @returns {Triangle[]} 24 | */ 25 | convertToTriangles () { 26 | let tesselator = new Tesselator() 27 | return tesselator.triangulate(this.getVertices()) 28 | } 29 | } 30 | 31 | export default LinearRing 32 | -------------------------------------------------------------------------------- /bin/citygml-to-3dtiles.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import caporal from 'caporal' 4 | import Converter from '../src/Converter.mjs' 5 | import fs from 'fs' 6 | 7 | caporal 8 | .argument('', 'Input path of CityGML XML file, or folder with multiple files', (path) => { 9 | if (!fs.existsSync(path)) { 10 | throw new Error('File does not exist: ' + path) 11 | } 12 | return path 13 | }) 14 | .argument('', 'Output folder where to create 3D-Tiles') 15 | .action(async function (args, options, logger) { 16 | let converter = new Converter() 17 | logger.info('Converting...') 18 | await converter.convertFiles(args['inputCitygml'], args['output3Dtiles']) 19 | logger.info('Done.') 20 | }) 21 | 22 | caporal.parse(process.argv) 23 | -------------------------------------------------------------------------------- /src/geometry/Triangle.mjs: -------------------------------------------------------------------------------- 1 | import Cesium from 'cesium' 2 | 3 | const Cartesian3 = Cesium.Cartesian3 4 | 5 | class Triangle { 6 | /** 7 | * @param {Cesium.Cartesian3[]} vertices 8 | */ 9 | constructor (vertices) { 10 | if (vertices.length !== 3) { 11 | throw new Error('Invalid vertices length for triangle: ' + vertices.length) 12 | } 13 | this.vertices = vertices 14 | } 15 | 16 | /** 17 | * @returns {Cesium.Cartesian3[]} 18 | */ 19 | getVertices () { 20 | return this.vertices 21 | } 22 | 23 | /** 24 | * @returns Cesium.Cartesian3 25 | */ 26 | getNormal () { 27 | let u = Cartesian3.subtract(this.vertices[1], this.vertices[0], new Cartesian3()) 28 | let v = Cartesian3.subtract(this.vertices[2], this.vertices[0], new Cartesian3()) 29 | return Cartesian3.cross(u, v, new Cartesian3()) 30 | } 31 | 32 | } 33 | 34 | export default Triangle 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "citygml-to-3dtiles", 3 | "version": "0.2.6", 4 | "repository": "https://github.com/njam/citygml-to-3dtiles", 5 | "license": "Apache-2.0", 6 | "engines": { 7 | "node": ">=13.0.0" 8 | }, 9 | "dependencies": { 10 | "caporal": "^1.4.0", 11 | "cesium": "1.43.0", 12 | "convexhull-js": "^1.0.0", 13 | "fs-extra": "^5.0.0", 14 | "gltf-pipeline": "^1.0.7", 15 | "i": "^0.3.7", 16 | "libtess": "^1.2.2", 17 | "npm": "^9.1.3", 18 | "proj4": "^2.4.4", 19 | "quickhull3d": "^2.0.3", 20 | "xmldom": "^0.5.0", 21 | "xpath": "0.0.24" 22 | }, 23 | "devDependencies": { 24 | "chai": "^4.1.2", 25 | "mocha": "^10.1.0", 26 | "fs-jetpack": "^5.1.0" 27 | }, 28 | "scripts": { 29 | "test": "node_modules/.bin/mocha" 30 | }, 31 | "main": "./src/Converter.mjs", 32 | "bin": { 33 | "citygml-to-3dtiles": "bin/citygml-to-3dtiles.mjs" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/3dtiles/Material.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on: 3 | * https://github.com/AnalyticalGraphicsInc/3d-tiles-tools/blob/master/samples-generator/lib/Material.js 4 | */ 5 | import Cesium from 'cesium' 6 | 7 | export default Material 8 | 9 | let defaultValue = Cesium.defaultValue 10 | 11 | /** 12 | * A material that is applied to a mesh. 13 | * 14 | * @param {Object} [options] An object with the following properties: 15 | * @param {Array|String} [options.ambient] The ambient color or ambient texture path. 16 | * @param {Array|String} [options.diffuse] The diffuse color or diffuse texture path. 17 | * @param {Array|String} [options.emission] The emission color or emission texture path. 18 | * @param {Array|String} [options.specular] The specular color or specular texture path. 19 | * @param {Number} [options.shininess=0.0] The specular shininess. 20 | * 21 | * @constructor 22 | */ 23 | function Material (options) { 24 | options = defaultValue(options, defaultValue.EMPTY_OBJECT) 25 | this.ambient = defaultValue(options.ambient, [0.0, 0.0, 0.0, 1.0]) 26 | this.diffuse = defaultValue(options.diffuse, [0.5, 0.5, 0.5, 1.0]) 27 | this.emission = defaultValue(options.emission, [0.0, 0.0, 0.0, 1.0]) 28 | this.specular = defaultValue(options.specular, [0.0, 0.0, 0.0, 1.0]) 29 | this.shininess = defaultValue(options.shininess, 0.0) 30 | } 31 | -------------------------------------------------------------------------------- /src/3dtiles/Tileset.mjs: -------------------------------------------------------------------------------- 1 | import Batched3DModel from './Batched3DModel.mjs' 2 | import fsExtra from 'fs-extra' 3 | import path from 'path' 4 | 5 | class Tileset { 6 | /** 7 | * @param {Batched3DModel} b3dm 8 | */ 9 | constructor (b3dm) { 10 | this.b3dm = b3dm 11 | } 12 | 13 | /** 14 | * @param {String} tileName 15 | * @returns {String} 16 | */ 17 | getJson (tileName) { 18 | let data = { 19 | asset: { 20 | version: '0.0' 21 | }, 22 | properties: this.b3dm.getBatchTable().getMinMax(), 23 | geometricError: 99, 24 | root: { 25 | refine: 'ADD', 26 | boundingVolume: { 27 | region: this.b3dm.getRegion() 28 | }, 29 | geometricError: 0.0, 30 | content: { 31 | url: tileName 32 | } 33 | } 34 | } 35 | return JSON.stringify(data, null, 2) 36 | } 37 | 38 | /** 39 | * @returns {Batched3DModel} 40 | */ 41 | getBatched3DModel () { 42 | return this.b3dm 43 | } 44 | 45 | /** 46 | * @param {String} folder 47 | * @returns {Promise} 48 | */ 49 | async writeToFolder (folder) { 50 | fsExtra.outputFile(path.join(folder, 'full.b3dm'), await this.b3dm.getBuffer()) 51 | fsExtra.outputFile(path.join(folder, 'tileset.json'), this.getJson('full.b3dm')) 52 | } 53 | } 54 | 55 | export default Tileset 56 | -------------------------------------------------------------------------------- /src/citygml/Document.mjs: -------------------------------------------------------------------------------- 1 | import xmldom from 'xmldom' 2 | import CityModel from './CityModel.mjs' 3 | import CityNode from './CityNode.mjs' 4 | import SRSTranslator from './SRSTranslator.mjs' 5 | import fs from 'fs' 6 | 7 | class Document { 8 | 9 | /** 10 | * @param {Document} xmlDoc 11 | * @param {SRSTranslator} srsTranslator 12 | */ 13 | constructor (xmlDoc, srsTranslator) { 14 | this.xmlDoc = xmlDoc 15 | this.srsTranslator = srsTranslator 16 | } 17 | 18 | /** 19 | * @returns {SRSTranslator} 20 | */ 21 | getSRSTranslator () { 22 | return this.srsTranslator 23 | } 24 | 25 | /** 26 | * @returns {CityModel} 27 | */ 28 | getCityModel () { 29 | let rootNode = new CityNode(this.xmlDoc, this) 30 | let cityModelNode = rootNode.selectCityNode('./(citygml1:CityModel|citygml2:CityModel)') 31 | return new CityModel(cityModelNode) 32 | } 33 | 34 | /** 35 | * @param {String} path 36 | * @param {SRSTranslator} [srsTranslator] 37 | * @returns {Document} 38 | */ 39 | static fromFile (path, srsTranslator) { 40 | if (!srsTranslator) { 41 | srsTranslator = new SRSTranslator() 42 | } 43 | let data = fs.readFileSync(path, 'utf8') 44 | let domParser = new xmldom.DOMParser({ 45 | locator: { 46 | systemId: path, 47 | }, 48 | }) 49 | let dom = domParser.parseFromString(data) 50 | return new Document(dom, srsTranslator) 51 | } 52 | 53 | } 54 | 55 | export default Document 56 | -------------------------------------------------------------------------------- /src/citygml/SRSTranslator.test.mjs: -------------------------------------------------------------------------------- 1 | import SRSTranslator from './SRSTranslator.mjs' 2 | import chai from 'chai' 3 | import assert from 'assert' 4 | 5 | describe('SRSTranslator', async function () { 6 | 7 | describe('#forward()', () => { 8 | let transformer = new SRSTranslator() 9 | 10 | it('should convert 2D coordinates', () => { 11 | chai.assert.deepEqual( 12 | transformer.forward([683303.518, 247425.762], 'CH1903', 'WGS84'), 13 | [8.541602004702368, 47.37240457906726] 14 | ) 15 | }) 16 | 17 | it('should preserve "height" component of 3D coordinates', () => { 18 | chai.assert.deepEqual( 19 | transformer.forward([683303.518, 247425.762, 300.4], 'CH1903', 'WGS84'), 20 | [8.541602004702368, 47.37240457906726, 300.4] 21 | ) 22 | }) 23 | 24 | it('should throw on unknown projection', () => { 25 | assert.throws(() => { 26 | transformer.forward([2, 3], 'XYZ123', 'WGS84') 27 | }, /Unknown projection/) 28 | }) 29 | }) 30 | 31 | describe('#constructor()', () => { 32 | describe('with custom projection definitions', () => { 33 | let transformer = new SRSTranslator({ 34 | 'EPSG:5243': '+proj=lcc +lat_1=48.66666666666666 +lat_2=53.66666666666666 +lat_0=51 +lon_0=10.5 +x_0=0 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs', 35 | }) 36 | 37 | it('should convert from custom projection', () => { 38 | chai.assert.deepEqual( 39 | transformer.forward([195927.24, 172450.97], 'EPSG:5243', 'WGS84'), 40 | [13.388888936536285, 52.5166670295793] 41 | ) 42 | }) 43 | }) 44 | }) 45 | 46 | }) 47 | -------------------------------------------------------------------------------- /src/3dtiles/Batched3DModel.mjs: -------------------------------------------------------------------------------- 1 | import BatchTable from './BatchTable.mjs' 2 | import createB3dm from './createB3dm.mjs' 3 | import Cesium from 'cesium' 4 | 5 | /** 6 | * @see https://github.com/CesiumGS/3d-tiles/blob/1.0/specification/TileFormats/Batched3DModel/README.md 7 | */ 8 | class Batched3DModel { 9 | /** 10 | * @param {Buffer} gltf 11 | * @param {BatchTable} batchTable 12 | * @param {BoundingBox} boundingBox 13 | */ 14 | constructor (gltf, batchTable, boundingBox) { 15 | this.gltf = gltf 16 | this.batchTable = batchTable 17 | this.boundingBox = boundingBox 18 | } 19 | 20 | /** 21 | * @returns {Buffer} 22 | */ 23 | async getBuffer () { 24 | return await createB3dm({ 25 | glb: this.gltf, 26 | featureTableJson: {BATCH_LENGTH: this.batchTable.getLength()}, 27 | batchTableJson: this.batchTable.getBatchTableJson() 28 | }) 29 | } 30 | 31 | /** 32 | * @returns {Number[]} 33 | */ 34 | getRegion () { 35 | let points = this.boundingBox.getPoints() 36 | let longitudes = [points[0].longitude, points[1].longitude] 37 | let latitudes = [points[0].latitude, points[1].latitude] 38 | let heights = [points[0].height, points[1].height] 39 | return [ 40 | Math.min(...longitudes), //west 41 | Math.min(...latitudes), // south 42 | Math.max(...longitudes), // east 43 | Math.max(...latitudes), // north 44 | Math.min(...heights), // bottom 45 | Math.max(...heights), // top 46 | ] 47 | } 48 | 49 | /** 50 | * @returns {BatchTable} 51 | */ 52 | getBatchTable () { 53 | return this.batchTable 54 | } 55 | } 56 | 57 | export default Batched3DModel 58 | -------------------------------------------------------------------------------- /src/citygml/CityObject/Building.mjs: -------------------------------------------------------------------------------- 1 | import LinearRing from '../../geometry/LinearRing.mjs' 2 | import TriangleMesh from '../../geometry/TriangleMesh.mjs' 3 | import CityObject from '../CityObject.mjs' 4 | 5 | class Building extends CityObject { 6 | 7 | /** 8 | * @param {CityNode} cityNode 9 | */ 10 | constructor (cityNode) { 11 | cityNode.assertLocalName('Building') 12 | super(cityNode) 13 | } 14 | 15 | /** 16 | * @returns {LinearRing[]} 17 | */ 18 | getLinearRings () { 19 | if (!this.rings) { 20 | this.rings = this.cityNode.selectCityNodes('.//gml:Polygon//gml:LinearRing') 21 | .map(ringNode => { 22 | let pos = ringNode.selectCityNodes('./gml:pos') 23 | let points = pos.map(n => n.getTextAsCoordinates1Cartesian()) 24 | if (points.length === 0) { 25 | points = ringNode.selectCityNode('./gml:posList').getTextAsCoordinatesCartesian() 26 | } 27 | 28 | if (points.length < 4) { 29 | console.error(`WARNING: Ignoring "LinearRing" with less than 4 points at ${ringNode.getDocumentURI()} line ${ringNode.getLineNumber()}`) 30 | return null 31 | } 32 | 33 | return new LinearRing(points) 34 | }) 35 | .filter(ring => !!ring) 36 | } 37 | return this.rings 38 | } 39 | 40 | /** 41 | * @returns {TriangleMesh} 42 | */ 43 | getTriangleMesh () { 44 | if (!this.triangleMesh) { 45 | let linearRings = this.getLinearRings() 46 | let triangles = linearRings.reduce((accumulator, ring) => { 47 | return accumulator.concat(ring.convertToTriangles()) 48 | }, []) 49 | this.triangleMesh = new TriangleMesh(triangles) 50 | } 51 | return this.triangleMesh 52 | } 53 | 54 | } 55 | 56 | export default Building 57 | -------------------------------------------------------------------------------- /src/citygml/CityModel.mjs: -------------------------------------------------------------------------------- 1 | import CityNode from './CityNode.mjs' 2 | import Building from './CityObject/Building.mjs' 3 | import BoundingBox from '../geometry/BoundingBox.mjs' 4 | import Envelope from './Envelope.mjs' 5 | 6 | class CityModel { 7 | /** 8 | * @param {CityNode} cityNode 9 | */ 10 | constructor (cityNode) { 11 | cityNode.assertLocalName('CityModel') 12 | this.cityNode = cityNode 13 | } 14 | 15 | /** 16 | * @returns {CityObject[]} 17 | */ 18 | getCityObjects () { 19 | /** 20 | * @todo include other city-objects 21 | */ 22 | return this.getBuildings() 23 | } 24 | 25 | /** 26 | * @returns {Building[]} 27 | */ 28 | getBuildings () { 29 | let nodes = this.cityNode.selectCityNodes('//(bldg1:Building|bldg2:Building)') 30 | return nodes.map((cityNode) => { 31 | return new Building(cityNode) 32 | }) 33 | } 34 | 35 | /** 36 | * @returns {Envelope|Null} 37 | */ 38 | getEnvelope () { 39 | let envelopeNode = this.cityNode.findCityNode('./gml:boundedBy/gml:Envelope') 40 | if (!envelopeNode) { 41 | return null 42 | } 43 | return new Envelope(envelopeNode) 44 | } 45 | 46 | /** 47 | * @returns {BoundingBox} 48 | */ 49 | getBoundingBox () { 50 | let documentEnvelope = this.getEnvelope() 51 | if (documentEnvelope) { 52 | return documentEnvelope.getBoundingBox() 53 | } 54 | 55 | let objectEnvelopes = this.getCityObjects().map(o => o.getEnvelope()) 56 | objectEnvelopes = objectEnvelopes.filter(e => !!e) 57 | if (objectEnvelopes.length > 0) { 58 | let boundingBoxes = objectEnvelopes.map(e => e.getBoundingBox()) 59 | return BoundingBox.fromBoundingBoxes(boundingBoxes) 60 | } 61 | 62 | throw new Error('Failed to get bounding box for city-model') 63 | } 64 | } 65 | 66 | export default CityModel 67 | -------------------------------------------------------------------------------- /src/geometry/BoundingBox.mjs: -------------------------------------------------------------------------------- 1 | import Cesium from 'cesium' 2 | 3 | class BoundingBox { 4 | /** 5 | * @param {Cesium.Cartographic} min 6 | * @param {Cesium.Cartographic} max 7 | */ 8 | constructor (min, max) { 9 | this.min = min 10 | this.max = max 11 | } 12 | 13 | /** 14 | * @returns {Cesium.Cartographic[]} 15 | */ 16 | getPoints () { 17 | return [this.min, this.max] 18 | } 19 | 20 | /** 21 | * @returns {Cesium.Cartographic} 22 | */ 23 | getMin () { 24 | return this.min 25 | } 26 | 27 | /** 28 | * @returns {Cesium.Cartographic} 29 | */ 30 | getMax () { 31 | return this.max 32 | } 33 | 34 | /** 35 | * @param {Cesium.Cartographic[]} points 36 | * @returns {BoundingBox} 37 | */ 38 | static fromPoints (points) { 39 | if (points.length < 1) { 40 | throw new Error('Invalid number of points: ' + points.length) 41 | } 42 | let min = points[0].clone() 43 | let max = min.clone() 44 | points.forEach(point => { 45 | min.longitude = Math.min(min.longitude, point.longitude) 46 | min.latitude = Math.min(min.latitude, point.latitude) 47 | min.height = Math.min(min.height, point.height) 48 | 49 | max.longitude = Math.max(max.longitude, point.longitude) 50 | max.latitude = Math.max(max.latitude, point.latitude) 51 | max.height = Math.max(max.height, point.height) 52 | }) 53 | return new BoundingBox(min, max) 54 | } 55 | 56 | /** 57 | * @param {BoundingBox[]} boxes 58 | * @returns {BoundingBox} 59 | */ 60 | static fromBoundingBoxes (boxes) { 61 | if (boxes.length < 1) { 62 | throw new Error('Invalid number of bounding-boxes: ' + boxes.length) 63 | } 64 | let points = [] 65 | boxes.forEach(box => { 66 | points = points.concat(box.getPoints()) 67 | }) 68 | return this.fromPoints(points) 69 | } 70 | 71 | } 72 | 73 | export default BoundingBox 74 | -------------------------------------------------------------------------------- /src/3dtiles/BatchTable.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://github.com/CesiumGS/3d-tiles/blob/1.0/specification/TileFormats/BatchTable/README.md 3 | */ 4 | class BatchTable { 5 | 6 | constructor () { 7 | this.items = {} 8 | } 9 | 10 | /** 11 | * @param {String|Number} batchId 12 | * @param {Object} properties 13 | */ 14 | addBatchItem (batchId, properties) { 15 | batchId = String(batchId) 16 | if (this.items[batchId]) { 17 | throw new Error('An item with this ID already exists: ' + batchId) 18 | } 19 | this.items[batchId] = properties 20 | } 21 | 22 | /** 23 | * @returns {String[]} 24 | */ 25 | getBatchIds () { 26 | return Object.keys(this.items) 27 | } 28 | 29 | /** 30 | * @returns {string[]} 31 | */ 32 | getPropertyNames () { 33 | let propertyNames = {} 34 | for (const id in this.items) { 35 | let properties = this.items[id] 36 | for (const name in properties) { 37 | propertyNames[name] = true 38 | } 39 | } 40 | return Object.keys(propertyNames) 41 | } 42 | 43 | /** 44 | * @returns {Object} 45 | */ 46 | getBatchTableJson () { 47 | let ids = this.getBatchIds() 48 | let propertyNames = this.getPropertyNames() 49 | 50 | let batchTable = { 51 | id: ids 52 | } 53 | propertyNames.forEach(name => { 54 | batchTable[name] = ids.map((id, i) => { 55 | let value = this.items[id][name] 56 | if (typeof value === 'undefined') { 57 | value = null 58 | } 59 | return value 60 | }) 61 | }) 62 | 63 | return batchTable 64 | } 65 | 66 | /** 67 | * @returns {Number} 68 | */ 69 | getLength () { 70 | return Object.keys(this.items).length 71 | } 72 | 73 | /** 74 | * @returns {Object} 75 | */ 76 | getMinMax () { 77 | let minmax = {} 78 | for (const id in this.items) { 79 | let properties = this.items[id] 80 | for (const name in properties) { 81 | let value = properties[name] 82 | if (typeof value === 'number') { 83 | if (!minmax[name]) { 84 | minmax[name] = {minimum: value, maximum: value} 85 | } else { 86 | minmax[name]['minimum'] = Math.min(minmax[name]['minimum'], value) 87 | minmax[name]['maximum'] = Math.max(minmax[name]['maximum'], value) 88 | } 89 | } 90 | } 91 | } 92 | return minmax 93 | } 94 | } 95 | 96 | export default BatchTable 97 | -------------------------------------------------------------------------------- /src/geometry/Tesselator.mjs: -------------------------------------------------------------------------------- 1 | import Cesium from 'cesium' 2 | import libtess from 'libtess' 3 | import Triangle from './Triangle.mjs' 4 | 5 | let tessy 6 | 7 | class Tesselator { 8 | 9 | /** 10 | * @param {Cesium.Cartesian3[]} vertices 11 | * @returns {Triangle[]} 12 | */ 13 | triangulate (vertices) { 14 | let points = [] 15 | vertices.forEach(v => { 16 | points.push(v.x, v.y, v.z) 17 | }) 18 | 19 | let result = [] 20 | let tessy = Tesselator._getTessy() 21 | tessy.gluTessBeginPolygon(result) 22 | tessy.gluTessBeginContour() 23 | vertices.forEach(v => { 24 | let coords = [v.x, v.y, v.z] 25 | tessy.gluTessVertex(coords, coords) 26 | }) 27 | tessy.gluTessEndContour() 28 | tessy.gluTessEndPolygon() 29 | 30 | let triangles = [] 31 | while (result.length > 0) { 32 | const triangle = new Triangle([ 33 | new Cesium.Cartesian3(result.shift(), result.shift(), result.shift()), 34 | new Cesium.Cartesian3(result.shift(), result.shift(), result.shift()), 35 | new Cesium.Cartesian3(result.shift(), result.shift(), result.shift()), 36 | ]) 37 | triangles.push(triangle) 38 | } 39 | return triangles 40 | } 41 | 42 | /** 43 | * @returns {libtess.GluTesselator} 44 | * @private 45 | */ 46 | static _getTessy () { 47 | if (!tessy) { 48 | tessy = new libtess.GluTesselator() 49 | tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_VERTEX_DATA, function (data, polyVertArray) { 50 | polyVertArray[polyVertArray.length] = data[0] 51 | polyVertArray[polyVertArray.length] = data[1] 52 | polyVertArray[polyVertArray.length] = data[2] 53 | }) 54 | tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_BEGIN, function (type) { 55 | if (type !== libtess.primitiveType.GL_TRIANGLES) { 56 | throw new Error('expected TRIANGLES but got type: ' + type) 57 | } 58 | }) 59 | tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_ERROR, function (errno) { 60 | throw new Error('libtess error: ' + errno) 61 | }) 62 | tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_COMBINE, function (coords, data, weight) { 63 | return [coords[0], coords[1], coords[2]] 64 | }) 65 | tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_EDGE_FLAG, function (flag) { 66 | // don't really care about the flag, but need no-strip/no-fan behavior 67 | }) 68 | } 69 | return tessy 70 | } 71 | 72 | } 73 | 74 | export default Tesselator 75 | -------------------------------------------------------------------------------- /src/citygml/SRSTranslator.mjs: -------------------------------------------------------------------------------- 1 | import proj4 from 'proj4' 2 | import epsgDefinitions from "./EpsgDefinitions.mjs"; 3 | 4 | class SRSTranslator { 5 | 6 | /** 7 | * @param {Object} [projectionDefinitions] 8 | */ 9 | constructor (projectionDefinitions) { 10 | this.projections = {} 11 | this.transformations = {} 12 | 13 | projectionDefinitions = Object.assign( 14 | SRSTranslator._getDefaultDefinitions(), 15 | projectionDefinitions || {} 16 | ) 17 | for (let name in projectionDefinitions) { 18 | this.addProjection(name, projectionDefinitions[name]) 19 | } 20 | } 21 | 22 | /** 23 | * @param {String} name 24 | * @param {String} projection 25 | */ 26 | addProjection (name, projection) { 27 | this.projections[name] = projection 28 | } 29 | 30 | /** 31 | * @param {Number[]} coords 32 | * @param {String} projectionFrom 33 | * @param {String} projectionTo 34 | * @return {Number[]} 35 | */ 36 | forward (coords, projectionFrom, projectionTo) { 37 | let height = undefined 38 | if (coords.length === 3) { 39 | // proj4js doesn't support 'height', just preserve the input value 40 | height = coords.pop() 41 | } 42 | 43 | let transformation = this._getTransformation(projectionFrom, projectionTo) 44 | coords = transformation.forward(coords) 45 | 46 | if (typeof height !== 'undefined') { 47 | coords[2] = height 48 | } 49 | return coords 50 | } 51 | 52 | /** 53 | * @param {String} projectionFrom 54 | * @param {String} projectionTo 55 | * @return {Function} 56 | */ 57 | _getTransformation (projectionFrom, projectionTo) { 58 | let cacheKey = `${projectionFrom}:::${projectionTo}` 59 | if (!this.transformations[cacheKey]) { 60 | let from = this._getProjection(projectionFrom) 61 | let to = this._getProjection(projectionTo) 62 | this.transformations[cacheKey] = proj4(from, to) 63 | } 64 | return this.transformations[cacheKey] 65 | } 66 | 67 | /** 68 | * @param {String} name 69 | * @return {String} 70 | * @private 71 | */ 72 | _getProjection (name) { 73 | if (!this.projections[name]) { 74 | throw new Error(`Unknown projection name: "${name}".\nSee https://github.com/njam/citygml-to-3dtiles#option-srsprojections for details.`) 75 | } 76 | return this.projections[name] 77 | } 78 | 79 | /** 80 | * @returns {Object} 81 | * @private 82 | */ 83 | static _getDefaultDefinitions () { 84 | return epsgDefinitions 85 | } 86 | 87 | } 88 | 89 | export default SRSTranslator 90 | -------------------------------------------------------------------------------- /src/3dtiles/parseB3dm.mjs: -------------------------------------------------------------------------------- 1 | export default parseB3dm 2 | 3 | /** 4 | * Parse a Batched 3D Model (b3dm) from binary data 5 | * 6 | * @see https://github.com/CesiumGS/3d-tiles/blob/1.0/specification/TileFormats/Batched3DModel/README.md 7 | * 8 | * @param {Buffer} buffer 9 | * @returns {ParsedB3dm} The parsed b3dm model 10 | */ 11 | function parseB3dm(buffer) { 12 | let headerLength = 28; 13 | if (buffer.length < headerLength) { 14 | throw new Error(`Expected ${headerLength} bytes but only got ${buffer.length}`); 15 | } 16 | 17 | // Read header 18 | const byteLength = buffer.readUInt32LE(8); 19 | let featureTableJsonLength = buffer.readUInt32LE(12); 20 | let featureTableBinaryLength = buffer.readUInt32LE(16); 21 | let batchTableJsonLength = buffer.readUInt32LE(20); 22 | let batchTableBinaryLength = buffer.readUInt32LE(24); 23 | 24 | // Calculate start/end of the sections 25 | const featureTableJsonStart = headerLength; 26 | const featureTableJsonEnd = featureTableJsonStart + featureTableJsonLength; 27 | const featureTableBinaryStart = featureTableJsonEnd; 28 | const featureTableBinaryEnd = featureTableBinaryStart + featureTableBinaryLength; 29 | const batchTableJsonStart = featureTableBinaryEnd; 30 | const batchTableJsonEnd = batchTableJsonStart + batchTableJsonLength; 31 | const batchTableBinaryStart = batchTableJsonEnd; 32 | const batchTableBinaryEnd = batchTableBinaryStart + batchTableBinaryLength; 33 | const payloadStart = batchTableBinaryEnd; 34 | const payloadEnd = byteLength; 35 | 36 | // Extract the data 37 | const batchTableJsonBuffer = buffer.subarray(batchTableJsonStart, batchTableJsonEnd); 38 | const payloadBuffer = buffer.subarray(payloadStart, payloadEnd); 39 | 40 | // Produce the parsed b3dm representation 41 | const batchTableJson = parseJsonFromBuffer(batchTableJsonBuffer); 42 | return new ParsedB3dm(batchTableJson, payloadBuffer) 43 | } 44 | 45 | /** 46 | * Parses JSON data from the given buffer. 47 | * 48 | * If the given buffer is empty, then an empty object will 49 | * be returned. 50 | * 51 | * @param {Buffer} buffer 52 | * @returns The parsed object 53 | * @throws DataError If the JSON could not be parsed 54 | */ 55 | function parseJsonFromBuffer(buffer) { 56 | if (buffer.length === 0) { 57 | return {}; 58 | } 59 | try { 60 | return JSON.parse(buffer.toString("utf8")); 61 | } catch (e) { 62 | throw new Error(`Could not parse JSON from buffer: ${e}`); 63 | } 64 | } 65 | 66 | 67 | class ParsedB3dm { 68 | /** 69 | * @param {Object} batchTable 70 | * @param {Buffer} gltf 71 | */ 72 | constructor(batchTable, gltf) { 73 | this.batchTable = batchTable 74 | this.gltf = gltf 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/citygml/CityObject.mjs: -------------------------------------------------------------------------------- 1 | import Envelope from './Envelope.mjs' 2 | 3 | class CityObject { 4 | 5 | /** 6 | * @param {CityNode} cityNode 7 | */ 8 | constructor (cityNode) { 9 | this.cityNode = cityNode 10 | } 11 | 12 | /** 13 | * @returns {Envelope|Null} 14 | */ 15 | getEnvelope () { 16 | let envelopeNode = this.cityNode.findCityNode('./gml:boundedBy/gml:Envelope') 17 | if (!envelopeNode) { 18 | return null 19 | } 20 | return new Envelope(envelopeNode) 21 | } 22 | 23 | /** 24 | * @returns {String} This object's ID ("gml:id") 25 | */ 26 | get id() { 27 | return this.cityNode.getAttribute('gml:id') 28 | } 29 | 30 | /** 31 | * @returns {TriangleMesh} 32 | */ 33 | getTriangleMesh () { 34 | throw new Error('Not implemented') 35 | } 36 | 37 | /** 38 | * @returns {Object} 39 | */ 40 | getAttributes () { 41 | const tagNames = [ 42 | 'gen1:stringAttribute', 43 | 'gen1:intAttribute', 44 | 'gen1:doubleAttribute', 45 | 'gen1:dateAttribute', 46 | 'gen1:uriAttribute', 47 | 'gen1:measureAttribute', 48 | 'gen2:stringAttribute', 49 | 'gen2:intAttribute', 50 | 'gen2:doubleAttribute', 51 | 'gen2:dateAttribute', 52 | 'gen2:uriAttribute', 53 | 'gen2:measureAttribute', 54 | ] 55 | const query = './/(' + tagNames.join('|') + ')' 56 | const attrs = {} 57 | this.cityNode.selectCityNodes(query).forEach(node => { 58 | const name = node.getAttribute('name') 59 | let value = node.selectNode('./(gen1:value|gen2:value)').textContent 60 | let tagName = node.getLocalName() 61 | if (tagName === 'intAttribute') { 62 | value = parseInt(value) 63 | } 64 | if (tagName === 'doubleAttribute' || tagName === 'measureAttribute') { 65 | value = parseFloat(value) 66 | } 67 | attrs[name] = value 68 | }) 69 | 70 | return attrs 71 | } 72 | 73 | /** 74 | * @returns {Object} 75 | */ 76 | getExternalReferences () { 77 | const refs = {} 78 | this.cityNode.selectCityNodes('./citygml1:externalReference').forEach(node => { 79 | const name = node.selectNode('./citygml1:informationSystem').textContent 80 | refs[name] = node.selectNode('./citygml1:externalObject/citygml1:name').textContent 81 | }) 82 | return refs 83 | } 84 | 85 | /** 86 | * @returns {Cesium.Cartesian3|Null} 87 | */ 88 | getAnyPoint () { 89 | let posLists = this.cityNode.selectCityNodes('.//gml:posList') 90 | if (posLists.length === 0) { 91 | return null 92 | } 93 | let coordinates = posLists[0].getTextAsCoordinatesCartesian() 94 | if (coordinates.length === 0) { 95 | return null 96 | } 97 | return coordinates[0] 98 | } 99 | } 100 | 101 | export default CityObject 102 | -------------------------------------------------------------------------------- /src/geometry/TriangleMesh.mjs: -------------------------------------------------------------------------------- 1 | import Cesium from 'cesium' 2 | import quickHull3d from 'quickhull3d' 3 | import Triangle from './Triangle.mjs' 4 | import convexHull from 'convexhull-js' 5 | 6 | const Cartesian3 = Cesium.Cartesian3 7 | const Cartesian2 = Cesium.Cartesian2 8 | 9 | class TriangleMesh { 10 | /** 11 | * @param {Triangle[]} triangles 12 | */ 13 | constructor (triangles) { 14 | this.triangles = triangles 15 | } 16 | 17 | /** 18 | * @returns {Triangle[]} 19 | */ 20 | getTriangles () { 21 | return this.triangles 22 | } 23 | 24 | /** 25 | * @returns {Cartesian3[]} 26 | */ 27 | getVertices () { 28 | let triangles = this.getTriangles() 29 | return triangles.reduce((accumulator, triangle) => { 30 | return accumulator.concat(triangle.getVertices()) 31 | }, []) 32 | } 33 | 34 | /** 35 | * @see https://stackoverflow.com/a/1568551/3090404 36 | * @returns {Number} 37 | */ 38 | getVolume () { 39 | let triangles = this.getHull() 40 | let zero 41 | let signedVolumeOfTriangle = (triangle) => { 42 | let vertices = triangle.getVertices() 43 | if (!zero) { 44 | zero = vertices[0] 45 | } 46 | let p1 = Cartesian3.subtract(zero, vertices[0], new Cartesian3()) 47 | let p2 = Cartesian3.subtract(zero, vertices[1], new Cartesian3()) 48 | let p3 = Cartesian3.subtract(zero, vertices[2], new Cartesian3()) 49 | let v321 = p3.x * p2.y * p1.z 50 | let v231 = p2.x * p3.y * p1.z 51 | let v312 = p3.x * p1.y * p2.z 52 | let v132 = p1.x * p3.y * p2.z 53 | let v213 = p2.x * p1.y * p3.z 54 | let v123 = p1.x * p2.y * p3.z 55 | return (1.0 / 6.0) * (-v321 + v231 + v312 - v132 - v213 + v123) 56 | } 57 | let volumes = triangles.map(t => signedVolumeOfTriangle(t)) 58 | let volume = volumes.reduce((acc, v) => acc + v, 0) 59 | return Math.abs(volume) 60 | } 61 | 62 | /** 63 | * @returns {Triangle[]} 64 | */ 65 | getHull () { 66 | let vertices = this.getVertices() 67 | let points = vertices.map(v => [v.x, v.y, v.z]) 68 | let output = quickHull3d(points) 69 | return output.map(indices => { 70 | return new Triangle([ 71 | vertices[indices[0]], 72 | vertices[indices[1]], 73 | vertices[indices[2]] 74 | ]) 75 | }) 76 | } 77 | 78 | /** 79 | * @return {Cesium.Cartesian2[]} 80 | */ 81 | getSurfaceHull () { 82 | let vertices = this.getVertices() 83 | let points = vertices.map(v => Cartesian2.fromCartesian3(v)) 84 | return convexHull(points) 85 | } 86 | 87 | /** 88 | * @see https://stackoverflow.com/a/43174368/3090404 89 | * @returns {Number} 90 | */ 91 | getSurfaceArea () { 92 | let points = this.getSurfaceHull() 93 | let area = 0 94 | points.forEach((vertex, i) => { 95 | let vertex2 = points[(i + 1) % points.length] 96 | area += vertex.x * vertex2.y - vertex.y * vertex2.x 97 | }) 98 | return Math.abs(area) / 2 99 | } 100 | 101 | } 102 | 103 | export default TriangleMesh 104 | -------------------------------------------------------------------------------- /src/3dtiles/BatchTable.test.mjs: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import BatchTable from './BatchTable.mjs' 3 | 4 | describe('BatchTable', function () { 5 | describe('#addFeature()', function () { 6 | it('should throw on duplicate ID', function () { 7 | let table = new BatchTable() 8 | table.addBatchItem('item1', {foo: 12}) 9 | 10 | assert.throws(() => { 11 | table.addBatchItem('item1', {foo: 13}) 12 | }, /already exists/) 13 | }) 14 | }) 15 | 16 | describe('#addFeature()', function () { 17 | it('should throw on duplicate ID, once string, once integer', function () { 18 | let table = new BatchTable() 19 | table.addBatchItem('2', {foo: 12}) 20 | 21 | assert.throws(() => { 22 | table.addBatchItem(2, {foo: 13}) 23 | }, /already exists/) 24 | }) 25 | }) 26 | 27 | describe('#getIds()', function () { 28 | it('should return all IDs', function () { 29 | let table = new BatchTable() 30 | table.addBatchItem('item1', {foo: 12, bar: 'bar1'}) 31 | table.addBatchItem('item2', {foo: 44}) 32 | table.addBatchItem('item3', {foo: 99, bar: 'bar3'}) 33 | 34 | assert.deepEqual(table.getBatchIds(), ['item1', 'item2', 'item3']) 35 | }) 36 | }) 37 | 38 | describe('#getPropertyNames()', function () { 39 | it('should return all property names', function () { 40 | let table = new BatchTable() 41 | table.addBatchItem('item1', {foo: 12, bar: 'bar1'}) 42 | table.addBatchItem('item2', {foo: 44}) 43 | table.addBatchItem('item3', {foo: 99, bar: 'bar3'}) 44 | 45 | assert.deepEqual(table.getPropertyNames(), ['foo', 'bar']) 46 | }) 47 | }) 48 | 49 | describe('#getBatchTableJson()', function () { 50 | it('should return a valid batch table', function () { 51 | let table = new BatchTable() 52 | table.addBatchItem('item1', {foo: 12, bar: 'bar1'}) 53 | table.addBatchItem('item2', {foo: 44}) 54 | table.addBatchItem('item3', {foo: 99, bar: 'bar3'}) 55 | 56 | assert.deepEqual(table.getBatchTableJson(), 57 | { 58 | foo: [12, 44, 99], 59 | bar: ['bar1', null, 'bar3'], 60 | id: ['item1', 'item2', 'item3'], 61 | }) 62 | }) 63 | }) 64 | 65 | describe('#getLength()', function () { 66 | it('should return the number of features', function () { 67 | let table = new BatchTable() 68 | table.addBatchItem('item1', {foo: 12, bar: 'bar1'}) 69 | table.addBatchItem('item2', {foo: 44}) 70 | table.addBatchItem('item3', {foo: 99, bar: 'bar3'}) 71 | 72 | assert.equal(table.getLength(), 3) 73 | }) 74 | }) 75 | 76 | describe('#getMinMax()', function () { 77 | it('should return the minimum and maximum values of numeric properties', function () { 78 | let table = new BatchTable() 79 | table.addBatchItem('item4', {foo: 4, bar: 44, str: 'str4'}) 80 | table.addBatchItem('item1', {foo: 1, bar: 'str11', str: 'str1'}) 81 | table.addBatchItem('item2', {foo: 2}) 82 | table.addBatchItem('item3', {foo: 3, bar: 33, str: 'str3'}) 83 | 84 | assert.deepEqual(table.getMinMax(), { 85 | foo: {minimum: 1, maximum: 4}, 86 | bar: {minimum: 33, maximum: 44}, 87 | }) 88 | }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /src/geometry/TriangleMesh.test.mjs: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | import TriangleMesh from './TriangleMesh.mjs' 3 | import Triangle from './Triangle.mjs' 4 | import Cesium from 'cesium' 5 | 6 | let Cartesian3 = Cesium.Cartesian3 7 | let Cartesian2 = Cesium.Cartesian2 8 | 9 | describe('TriangleMesh', async function () { 10 | let mesh = exampleMesh() 11 | 12 | describe('#getTriangles()', () => { 13 | it('should return the correct number of triangles', function () { 14 | chai.assert.lengthOf(mesh.getTriangles(), 12) 15 | }) 16 | it('should return instances of "Triangle"', function () { 17 | chai.assert.instanceOf(mesh.getTriangles()[0], Triangle) 18 | }) 19 | }) 20 | 21 | describe('#getVertices()', () => { 22 | it('should return the correct number of vertices', function () { 23 | chai.assert.lengthOf(mesh.getVertices(), 12 * 3) 24 | }) 25 | it('should return instances of "Cartesian3"', function () { 26 | chai.assert.instanceOf(mesh.getVertices()[0], Cartesian3) 27 | }) 28 | }) 29 | 30 | describe('#getVolume()', () => { 31 | it('should return the correct result', function () { 32 | chai.assert.closeTo(mesh.getVolume(), 37.3, 0.5) 33 | }) 34 | }) 35 | 36 | describe('#getHull()', () => { 37 | it('should return the correct number of triangles', function () { 38 | chai.assert.lengthOf(mesh.getHull(), 12) 39 | }) 40 | it('should return instances of "Triangle"', function () { 41 | chai.assert.instanceOf(mesh.getHull()[0], Triangle) 42 | }) 43 | }) 44 | 45 | describe('#getSurfaceHull()', () => { 46 | it('should return the correct number of points', function () { 47 | chai.assert.lengthOf(mesh.getSurfaceHull(), 6) 48 | }) 49 | it('should return instances of "Cartesian2"', function () { 50 | chai.assert.instanceOf(mesh.getSurfaceHull()[0], Cartesian2) 51 | }) 52 | }) 53 | 54 | describe('#getSurfaceArea()', () => { 55 | it('should return the correct result', function () { 56 | chai.assert.closeTo(mesh.getSurfaceArea(), 15.0, 0.5) 57 | }) 58 | }) 59 | 60 | }) 61 | 62 | function exampleMesh () { 63 | return new TriangleMesh([ 64 | new Triangle([new Cartesian3(0, 0, 0), new Cartesian3(-2.2, -0.1, 2), new Cartesian3(-2.4, 1.6, 1.9)]), 65 | new Triangle([new Cartesian3(0, 0, 0), new Cartesian3(-2.4, 1.6, 1.9), new Cartesian3(-0.5, 1.3, 0.3)]), 66 | new Triangle([new Cartesian3(-2.2, -0.1, 2), new Cartesian3(0, 0, 0), new Cartesian3(5.5, 0.8, 6.1)]), 67 | new Triangle([new Cartesian3(-2.2, -0.1, 2), new Cartesian3(5.5, 0.8, 6.1), new Cartesian3(3.3, 0.7, 8.1)]), 68 | new Triangle([new Cartesian3(0, 0, 0), new Cartesian3(-0.5, 1.3, 0.3), new Cartesian3(5.2, 2.2, 6.5)]), 69 | new Triangle([new Cartesian3(0, 0, 0), new Cartesian3(5.2, 2.2, 6.5), new Cartesian3(5.5, 0.8, 6.1)]), 70 | new Triangle([new Cartesian3(-0.5, 1.3, 0.3), new Cartesian3(-2.4, 1.6, 1.9), new Cartesian3(3.3, 2.5, 8.2)]), 71 | new Triangle([new Cartesian3(-0.5, 1.3, 0.3), new Cartesian3(3.3, 2.5, 8.2), new Cartesian3(5.2, 2.2, 6.5)]), 72 | new Triangle([new Cartesian3(-2.4, 1.6, 1.9), new Cartesian3(-2.2, -0.1, 2), new Cartesian3(3.3, 0.7, 8.1)]), 73 | new Triangle([new Cartesian3(-2.4, 1.6, 1.9), new Cartesian3(3.3, 0.7, 8.1), new Cartesian3(3.3, 2.5, 8.2)]), 74 | new Triangle([new Cartesian3(5.6, 0.6, 6), new Cartesian3(5.2, 2.2, 6.5), new Cartesian3(3.3, 2.5, 8.2)]), 75 | new Triangle([new Cartesian3(5.6, 0.6, 6), new Cartesian3(3.3, 2.5, 8.2), new Cartesian3(3.3, 0.7, 8.1)]), 76 | ]) 77 | } 78 | -------------------------------------------------------------------------------- /src/3dtiles/createB3dm.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on: 3 | * https://github.com/AnalyticalGraphicsInc/3d-tiles-tools/blob/master/samples-generator/lib/createB3dm.js 4 | */ 5 | import Cesium from 'cesium' 6 | 7 | export default createB3dm 8 | 9 | let defaultValue = Cesium.defaultValue 10 | let defined = Cesium.defined 11 | 12 | /** 13 | * Create a Batched 3D Model (b3dm) tile from a binary glTF and per-feature metadata. 14 | * 15 | * @param {Object} options An object with the following properties: 16 | * @param {Buffer} options.glb The binary glTF buffer. 17 | * @param {Object} [options.featureTableJson] Feature table JSON. 18 | * @param {Buffer} [options.featureTableBinary] Feature table binary. 19 | * @param {Object} [options.batchTableJson] Batch table describing the per-feature metadata. 20 | * @param {Buffer} [options.batchTableBinary] The batch table binary. 21 | * @returns {Buffer} The generated b3dm tile buffer. 22 | */ 23 | function createB3dm (options) { 24 | let glb = options.glb 25 | 26 | let headerByteLength = 28 27 | let featureTableJson = getJsonBufferPadded(options.featureTableJson, headerByteLength) 28 | let featureTableBinary = getBufferPadded(options.featureTableBinary) 29 | let batchTableJson = getJsonBufferPadded(options.batchTableJson) 30 | let batchTableBinary = getBufferPadded(options.batchTableBinary) 31 | 32 | let version = 1 33 | let featureTableJsonByteLength = featureTableJson.length 34 | let featureTableBinaryByteLength = featureTableBinary.length 35 | let batchTableJsonByteLength = batchTableJson.length 36 | let batchTableBinaryByteLength = batchTableBinary.length 37 | let gltfByteLength = glb.length 38 | let byteLength = headerByteLength + featureTableJsonByteLength + featureTableBinaryByteLength + batchTableJsonByteLength + batchTableBinaryByteLength + gltfByteLength 39 | 40 | let header = Buffer.alloc(headerByteLength) 41 | header.write('b3dm', 0) 42 | header.writeUInt32LE(version, 4) 43 | header.writeUInt32LE(byteLength, 8) 44 | header.writeUInt32LE(featureTableJsonByteLength, 12) 45 | header.writeUInt32LE(featureTableBinaryByteLength, 16) 46 | header.writeUInt32LE(batchTableJsonByteLength, 20) 47 | header.writeUInt32LE(batchTableBinaryByteLength, 24) 48 | 49 | return Buffer.concat([header, featureTableJson, featureTableBinary, batchTableJson, batchTableBinary, glb]) 50 | } 51 | 52 | /** 53 | * Pad the buffer to the next 8-byte boundary to ensure proper alignment for the section that follows. 54 | * Padding is not required by the 3D Tiles spec but is important when using Typed Arrays in JavaScript. 55 | * 56 | * @param {Buffer} buffer The buffer. 57 | * @param {Number} [byteOffset=0] The byte offset on which the buffer starts. 58 | * @returns {Buffer} The padded buffer. 59 | */ 60 | function getBufferPadded (buffer, byteOffset) { 61 | if (!defined(buffer)) { 62 | return Buffer.alloc(0) 63 | } 64 | 65 | byteOffset = defaultValue(byteOffset, 0) 66 | 67 | let boundary = 8 68 | let byteLength = buffer.length 69 | let remainder = (byteOffset + byteLength) % boundary 70 | let padding = (remainder === 0) ? 0 : boundary - remainder 71 | let emptyBuffer = Buffer.alloc(padding) 72 | return Buffer.concat([buffer, emptyBuffer]) 73 | } 74 | 75 | /** 76 | * Convert the JSON object to a padded buffer. 77 | * 78 | * Pad the JSON with extra whitespace to fit the next 8-byte boundary. This ensures proper alignment 79 | * for the section that follows (for example, batch table binary or feature table binary). 80 | * Padding is not required by the 3D Tiles spec but is important when using Typed Arrays in JavaScript. 81 | * 82 | * @param {Object} [json] The JSON object. 83 | * @param {Number} [byteOffset=0] The byte offset on which the buffer starts. 84 | * @returns {Buffer} The padded JSON buffer. 85 | */ 86 | function getJsonBufferPadded (json, byteOffset) { 87 | if (!defined(json)) { 88 | return Buffer.alloc(0) 89 | } 90 | 91 | byteOffset = defaultValue(byteOffset, 0) 92 | let string = JSON.stringify(json) 93 | 94 | let boundary = 8 95 | let byteLength = Buffer.byteLength(string) 96 | let remainder = (byteOffset + byteLength) % boundary 97 | let padding = (remainder === 0) ? 0 : boundary - remainder 98 | let whitespace = '' 99 | for (let i = 0; i < padding; ++i) { 100 | whitespace += ' ' 101 | } 102 | string += whitespace 103 | 104 | return Buffer.from(string) 105 | } 106 | -------------------------------------------------------------------------------- /src/Converter.mjs: -------------------------------------------------------------------------------- 1 | import BatchTable from './3dtiles/BatchTable.mjs' 2 | import CityDocument from './citygml/Document.mjs' 3 | import BoundingBox from './geometry/BoundingBox.mjs' 4 | import Mesh from './3dtiles/Mesh.mjs' 5 | import createGltf from './3dtiles/createGltf.mjs' 6 | import Batched3DModel from './3dtiles/Batched3DModel.mjs' 7 | import Tileset from './3dtiles/Tileset.mjs' 8 | import SRSTranslator from './citygml/SRSTranslator.mjs' 9 | import fs from 'fs' 10 | import Path from 'path' 11 | 12 | class Converter { 13 | 14 | /** 15 | * @param {Object} [options] 16 | */ 17 | constructor (options) { 18 | this.options = Object.assign({ 19 | propertiesGetter: null, 20 | objectFilter: null, 21 | srsProjections: {} 22 | }, options) 23 | } 24 | 25 | /** 26 | * @param {String} inputPath Path to CityGML XML file, or folder with multiple files 27 | * @param {String} outputFolder Path to folder to write 3D-Tiles files to 28 | */ 29 | async convertFiles (inputPath, outputFolder) { 30 | let inputPaths = this._findInputFiles(inputPath) 31 | let srsTranslator = new SRSTranslator(this.options.srsProjections) 32 | 33 | let cityObjects = [], boundingBoxes = [] 34 | inputPaths.forEach((inputPath, i) => { 35 | console.debug(`Reading CityGML file ${i + 1}/${inputPaths.length}...`) 36 | let cityDocument = CityDocument.fromFile(inputPath, srsTranslator) 37 | let cityModel = cityDocument.getCityModel() 38 | let objs = cityModel.getCityObjects() 39 | console.debug(` Found ${objs.length} city objects.`) 40 | if (this.options.objectFilter) { 41 | objs = objs.filter(this.options.objectFilter) 42 | console.debug(` After filtering ${objs.length} city objects remain.`) 43 | } 44 | if (objs.length > 0) { 45 | cityObjects.push(...objs) 46 | boundingBoxes.push(cityModel.getBoundingBox()) 47 | } 48 | }) 49 | 50 | console.debug(`Converting to 3D Tiles...`) 51 | let boundingBox = BoundingBox.fromBoundingBoxes(boundingBoxes) 52 | let tileset = await this.convertCityObjects(cityObjects, boundingBox) 53 | 54 | console.debug(`Writing 3D Tiles...`) 55 | await tileset.writeToFolder(outputFolder) 56 | console.debug('Done.') 57 | } 58 | 59 | /** 60 | * @param {Document} cityDocument 61 | * @returns {Tileset} 62 | */ 63 | async convertCityDocument (cityDocument) { 64 | let cityModel = cityDocument.getCityModel() 65 | return await this.convertCityObjects(cityModel.getCityObjects(), cityModel.getBoundingBox()) 66 | } 67 | 68 | /** 69 | * @param {CityObject[]} cityObjects 70 | * @param {BoundingBox} boundingBox 71 | * @returns {Tileset} 72 | */ 73 | async convertCityObjects (cityObjects, boundingBox) { 74 | let meshes = cityObjects.map((cityObject) => { 75 | return Mesh.fromTriangleMesh(cityObject.getTriangleMesh()) 76 | }) 77 | let mesh = Mesh.batch(meshes) 78 | 79 | let batchTable = new BatchTable() 80 | cityObjects.forEach((cityObject, i) => { 81 | batchTable.addBatchItem(i, this._getProperties(cityObject)) 82 | }) 83 | 84 | let gltf = await createGltf({ 85 | mesh: mesh, 86 | useBatchIds: true, 87 | optimizeForCesium: true, 88 | relativeToCenter: true 89 | }) 90 | 91 | let b3dm = new Batched3DModel(gltf, batchTable, boundingBox) 92 | 93 | return new Tileset(b3dm) 94 | } 95 | 96 | /** 97 | * @param {CityObject} cityObject 98 | * @returns {Object} 99 | * @private 100 | */ 101 | _getProperties (cityObject) { 102 | let properties = Object.assign( 103 | cityObject.getExternalReferences(), 104 | cityObject.getAttributes(), 105 | ) 106 | if (this.options.propertiesGetter) { 107 | properties = Object.assign(properties, 108 | this.options.propertiesGetter(cityObject, properties) 109 | ) 110 | } 111 | return properties 112 | } 113 | 114 | /** 115 | * @param {String} path 116 | * @returns {String[]} 117 | * @private 118 | */ 119 | _findInputFiles (path) { 120 | if (!fs.existsSync(path)) { 121 | throw new Error(`Input path does not exist: ${path}`) 122 | } 123 | 124 | let paths 125 | if (fs.statSync(path).isDirectory()) { 126 | paths = fs.readdirSync(path) 127 | .map((filename) => Path.join(path, filename)) 128 | .filter((path) => fs.statSync(path).isFile()) 129 | } else { 130 | paths = [path] 131 | } 132 | paths = paths.filter((path) => ['.xml', '.gml'].includes(Path.extname(path))) 133 | 134 | if (paths.length < 1) { 135 | throw new Error(`Could not find any .xml/.gml files in path: ${path}`) 136 | } 137 | 138 | return paths 139 | } 140 | } 141 | 142 | export default Converter 143 | -------------------------------------------------------------------------------- /docs/background.md: -------------------------------------------------------------------------------- 1 | 2 | CityGML 3 | ------- 4 | [CityGML][citygml] is an XML standard for describing 3D models of cities. It's based on another XML standard called *Geography Markup Language* (GML). Both standards are developed by the standardization consortium OGC. 5 | 6 | The basics of CityGML are very intuitive and easy to understand. The document's root node is a `CityModel`, which contains a list of `CityObject`s. These city objects can be of type *Building*, *Bridge*, *Vegetation*, *Tunnel* and a few others. Each city object can have some attributes to describe its characteristics, and will contain some *GML features* to define its geometry. 7 | 8 | Imagine two buildings each with walls and a roof: 9 | 10 | ![](img/buildings-3d.png) 11 | 12 | The corresponding CityGML would contain a single `CityModel` and two `Building`s. Each building could have attributes like "yearOfConstruction" and "measuredHeight" and would contain a so called `lod1MultiSurface`. This entity would contain the necessary GML geometry to define the hull of the buildings, for example a list of polygons. 13 | 14 | ![](img/buildings-diagram.png) 15 | 16 | The raw, simplified XML would look something like this: 17 | 18 | ```xml 19 | 20 | 21 | 22 | 1940 23 | 16.4 24 | 25 | 26 | 27 | 28 | 29 | 30 | 683278.754 247335.818 409.3 31 | 683286.378 247338.829 409.3 32 | 683286.378 247338.829 425.7 33 | 683278.754 247335.818 425.7 34 | 683278.754 247335.818 409.3 35 | 36 | 37 | 38 | 39 | [...] 40 | 41 | 42 | 43 | 44 | 45 | 46 | [...] 47 | 48 | 49 | ``` 50 | 51 | The polygons defining the walls of the buildings contain a bunch of 3D coordinates (`gml:pos`). Each coordinate consists of three numbers: longitude, latitude and height. The coordinates are expressed in a specific coordinate-system (*spatial reference system* SRS), in this case the Swiss coordinate system "CH1903" as is defined with the attribute `srsName="CH1903"`. 52 | 53 | The commonly used coordinate system is "WGS84". The same polygon expressed as coordinates in WGS84 would look like this: 54 | 55 | ```xml 56 | 57 | 8.541258 47.3715983 409.3 58 | 8.5413594 47.3716244 409.3 59 | 8.5413594 47.3716244 425.7 60 | 8.541258 47.3715983 425.7 61 | 8.541258 47.3715983 409.3 62 | 63 | ``` 64 | 65 | CityGML gives us a way to describe a set of buildings (and other *city objects*) and the exact geometric shape of their walls and other exterior parts. 66 | 67 | 68 | 3D Tiles 69 | -------- 70 | [3D Tiles][3d-tiles] is a specification from Cesium to stream and render 3D meshes. One of its use cases is to progressively load 3D buildings on a map, when zooming in. 71 | 72 | A 3D Tiles dataset is described in a file called `tileset.json`. This file defines the area for which it contains data (longitude, latitude and height) and a URL where that data can be loaded: 73 | 74 | ```json 75 | { 76 | "root": { 77 | "boundingVolume": { 78 | "region": [ 79 | 0.14900111790831405, 0.8267774614090546, 80 | 0.14908570176240377, 0.8267920546921999, 81 | 409.3, 425.7 82 | ] 83 | }, 84 | "content": { 85 | "url": "buildings.b3dm" 86 | } 87 | } 88 | } 89 | ``` 90 | The tileset metadata file can also define how to load higher-resolution data for the same region when the client zooms in. But for our purpose we'll ignore that functionality and stick with the bare basics. 91 | 92 | Our data URL references a file called `buildings.b3dm` which is a file in the *Batched 3D Model* format. This file contains the raw data to render all models in our scene in the [GL transmission format (glTF)][gltf]. 93 | The two houses from before would be declared and rendered as 36 triangles. Each vertex of each triangle will have a numeric identifier to indicate which model it belongs to ("batchId"). 94 | 95 | ![](img/buildings-triangles.png) 96 | 97 | The B3DM file also contains a so called *Batch Table*, which stores custom properties for the models in the scene. For our two buildings that could look like this: 98 | 99 | ```json 100 | { 101 | "id": [1, 2], 102 | "yearOfConstruction": [1940, 1902], 103 | "measuredHeight": [16.4, 20.3] 104 | } 105 | ``` 106 | 107 | Together these two pieces of information (models' geometry and models' properties) allow to render buildings and to visualize the values of properties in different ways. 108 | 109 | 110 | 111 | [citygml]: https://www.citygml.org/ 112 | [3d-tiles]: https://github.com/CesiumGS/3d-tiles 113 | [gltf]: https://www.khronos.org/gltf/ 114 | -------------------------------------------------------------------------------- /test/cli.test.mjs: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | import fsJetpack from 'fs-jetpack' 3 | import fs from 'node:fs' 4 | import {promisify} from 'node:util' 5 | import {exec} from 'node:child_process' 6 | import parseB3dm from "../src/3dtiles/parseB3dm.mjs"; 7 | 8 | const execAsync = promisify(exec) 9 | 10 | describe('CLI', async function () { 11 | this.timeout(10000) 12 | 13 | describe('zurich-lod2-citygml1.xml', () => { 14 | let outputFolder 15 | let tilesetJson 16 | let b3dmParsed 17 | 18 | before(async () => { 19 | let inputPath = 'test/data/zurich-lod2-citygml1.xml' 20 | outputFolder = fsJetpack.tmpDir({prefix: 'citygml-to-3dtiles---tests---'}) 21 | await execAsync(`./bin/citygml-to-3dtiles.mjs '${inputPath}' '${outputFolder.path()}'`) 22 | 23 | tilesetJson = JSON.parse(fs.readFileSync(outputFolder.path('tileset.json'))) 24 | b3dmParsed = parseB3dm(fs.readFileSync(outputFolder.path('full.b3dm'))) 25 | }) 26 | 27 | after(async () => { 28 | outputFolder.remove() 29 | }) 30 | 31 | it('The tileset should have the expected content', () => { 32 | chai.assert.deepEqual(tilesetJson, { 33 | "asset": { 34 | "version": "0.0", 35 | }, 36 | "geometricError": 99, 37 | "properties": { 38 | "Geomtype": { 39 | "maximum": 2, 40 | "minimum": 1, 41 | }, 42 | "QualitaetStatus": { 43 | "maximum": 1, 44 | "minimum": 1, 45 | }, 46 | "Region": { 47 | "maximum": 8, 48 | "minimum": 8, 49 | }, 50 | }, 51 | "root": { 52 | "boundingVolume": { 53 | "region": [ 54 | 0.14907155162642596, 55 | 0.826804434491562, 56 | 0.14908311706269073, 57 | 0.8268148017875332, 58 | 403.19936, 59 | 432.65393, 60 | ], 61 | }, 62 | "content": { 63 | "url": "full.b3dm", 64 | }, 65 | "geometricError": 0, 66 | "refine": "ADD", 67 | } 68 | }) 69 | }) 70 | 71 | it('The B3DM-gltf should look reasonable', async () => { 72 | chai.assert.include(b3dmParsed.gltf.toString(), 'void main(') 73 | }) 74 | 75 | it('The B3DM-BatchTable should have the expected content', () => { 76 | chai.assert.deepEqual(b3dmParsed.batchTable, { 77 | 'id': ['0', '1', '2', '3'], 78 | 'GID': ['z41ac39cc00001bb7', 'z45b11a6a00002f9e', 'z41ac39cc00001a71', 'z46f29566000001f7'], 79 | 'EGID': ['302040712', '2372759', '302040570', '140715'], 80 | 'E:\\Working\\08_20120322\\COWI_LoD2_08_20120322_part06.xml': ['z41ac39cc00001bb7', 'z45b11a6a00002f9e', 'z41ac39cc00001a71', 'z46f29566000001f7'], 81 | 'Region': [8, 8, 8, 8], 82 | 'QualitaetStatus': [1, 1, 1, 1], 83 | 'Geomtype': [1, 2, 1, 2] 84 | }) 85 | }) 86 | }) 87 | 88 | 89 | describe('sig3d-genericattributes-citygml2.xml', () => { 90 | let outputFolder 91 | let tilesetJson 92 | let b3dmParsed 93 | 94 | before(async () => { 95 | let inputPath = 'test/data/sig3d-genericattributes-citygml2.xml' 96 | outputFolder = fsJetpack.tmpDir({prefix: 'citygml-to-3dtiles---tests---'}) 97 | await execAsync(`./bin/citygml-to-3dtiles.mjs '${inputPath}' '${outputFolder.path()}'`) 98 | 99 | tilesetJson = JSON.parse(fs.readFileSync(outputFolder.path('tileset.json'))) 100 | b3dmParsed = parseB3dm(fs.readFileSync(outputFolder.path('full.b3dm'))) 101 | }) 102 | 103 | after(async () => { 104 | outputFolder.remove() 105 | }) 106 | 107 | it('The tileset should have the expected region', () => { 108 | chai.assert.deepEqual(tilesetJson['root']['boundingVolume']['region'], [ 109 | -0.8846020828104612, 110 | 1.5515922059263958, 111 | -0.8845940828005763, 112 | 1.551593676293702, 113 | 0, 114 | 9, 115 | ]) 116 | }) 117 | 118 | it('The B3DM-gltf should look reasonable', async () => { 119 | chai.assert.include(b3dmParsed.gltf.toString(), 'void main(') 120 | }) 121 | 122 | it('The B3DM-BatchTable should have the expected content', () => { 123 | chai.assert.deepEqual(b3dmParsed.batchTable, { 124 | 'id': ['0'], 125 | 'Bauweise': ['Massivbau'], 126 | 'Anzahl der Eingänge': [3], 127 | 'Grundflächenzahl GFZ': [0.33], 128 | 'Datum der Baufreigabe': ['2012-03-09'], 129 | 'Web Seite': ['http://www.sig3d.org'], 130 | 'Breite des Gebäudes': [20.75], 131 | 'Höhe': [9], 132 | 'Grundflächen': [140], 133 | 'Volumen': [1260], 134 | } 135 | ) 136 | }) 137 | }) 138 | 139 | describe('delft-citygml2.xml', () => { 140 | let outputFolder 141 | let tilesetJson 142 | let b3dmParsed 143 | 144 | before(async () => { 145 | let inputPath = 'test/data/delft-citygml2.xml' 146 | outputFolder = fsJetpack.tmpDir({prefix: 'citygml-to-3dtiles---tests---'}) 147 | await execAsync(`./bin/citygml-to-3dtiles.mjs '${inputPath}' '${outputFolder.path()}'`) 148 | 149 | tilesetJson = JSON.parse(fs.readFileSync(outputFolder.path('tileset.json'))) 150 | b3dmParsed = parseB3dm(fs.readFileSync(outputFolder.path('full.b3dm'))) 151 | }) 152 | 153 | after(async () => { 154 | outputFolder.remove() 155 | }) 156 | 157 | it('The tileset should have the expected region', () => { 158 | chai.assert.deepEqual(tilesetJson['root']['boundingVolume']['region'], [ 159 | 0.07601011853885127, 160 | 0.9075773939506161, 161 | 0.07648148496824216, 162 | 0.9078300429446181, 163 | 0, 164 | 100, 165 | ]) 166 | }) 167 | 168 | it('The B3DM-gltf should look reasonable', async () => { 169 | chai.assert.include(b3dmParsed.gltf.toString(), 'void main(') 170 | }) 171 | 172 | it('The B3DM-BatchTable should have the expected content', () => { 173 | chai.assert.deepEqual(b3dmParsed.batchTable, { 174 | 'id': ['0', '1', '2'], 175 | } 176 | ) 177 | }) 178 | }) 179 | 180 | }) 181 | -------------------------------------------------------------------------------- /src/citygml/CityNode.mjs: -------------------------------------------------------------------------------- 1 | import xpath from 'xpath' 2 | import Cesium from 'cesium' 3 | 4 | let srsCache = {} 5 | 6 | class CityNode { 7 | /** 8 | * @param {Node} xmlNode 9 | * @param {Document} document 10 | */ 11 | constructor (xmlNode, document) { 12 | this.xmlNode = xmlNode 13 | this.document = document 14 | } 15 | 16 | /** 17 | * @returns {String} 18 | */ 19 | getTagName () { 20 | return this.xmlNode.tagName 21 | } 22 | 23 | /** 24 | * @returns {String} 25 | */ 26 | getLocalName () { 27 | return this.xmlNode.localName 28 | } 29 | 30 | /** 31 | * @param {String} expectedName 32 | * @throws {Error} 33 | */ 34 | assertLocalName (expectedName) { 35 | let actualName = this.getLocalName() 36 | if (actualName !== expectedName) { 37 | throw new Error('Unexpected tagName, expected "' + expectedName + '" but got: ' + actualName) 38 | } 39 | } 40 | 41 | /** 42 | * @returns {String} 43 | */ 44 | getLineNumber () { 45 | return this.xmlNode.lineNumber 46 | } 47 | 48 | /** 49 | * @returns {String} 50 | */ 51 | getDocumentURI () { 52 | return this.xmlNode.ownerDocument.documentURI 53 | } 54 | 55 | /** 56 | * @param {String} name 57 | * @returns {String} 58 | */ 59 | getAttribute (name) { 60 | return this.xmlNode.getAttribute(name) 61 | } 62 | 63 | /** 64 | * @param {String} expression 65 | * @returns {Node|Null} 66 | */ 67 | selectNode (expression) { 68 | let node = this._select(expression, true) 69 | if (!node) { 70 | throw new Error('Cannot find node for expression: ' + expression) 71 | } 72 | return node 73 | } 74 | 75 | /** 76 | * @param {String} expression 77 | * @returns {Node|Null} 78 | */ 79 | findNode (expression) { 80 | let node = this._select(expression, true) 81 | if (!node) { 82 | return null 83 | } 84 | return node 85 | } 86 | 87 | /** 88 | * @param {String} expression 89 | * @returns {Node[]} 90 | */ 91 | selectNodes (expression) { 92 | return this._select(expression, false) 93 | } 94 | 95 | /** 96 | * @param {String} expression 97 | * @returns {CityNode[]} 98 | */ 99 | selectCityNodes (expression) { 100 | return this.selectNodes(expression).map((xmlNode) => { 101 | return new CityNode(xmlNode, this.document) 102 | }) 103 | } 104 | 105 | /** 106 | * @param {String} expression 107 | * @returns {CityNode} 108 | */ 109 | selectCityNode (expression) { 110 | return new CityNode(this.selectNode(expression), this.document) 111 | } 112 | 113 | /** 114 | * @param {String} expression 115 | * @returns {CityNode|Null} 116 | */ 117 | findCityNode (expression) { 118 | let xmlNode = this.findNode(expression) 119 | if (!xmlNode) { 120 | return null 121 | } 122 | return new CityNode(xmlNode, this.document) 123 | } 124 | 125 | /** 126 | * @returns {Cesium.Cartographic[]} 127 | */ 128 | getTextAsCoordinatesCartographic () { 129 | let srs = this.getSRS() 130 | let srsTranslator = this.document.getSRSTranslator() 131 | 132 | let text = this.xmlNode.textContent 133 | let textTokens = text.trim().split(' ') 134 | if (textTokens.length % 3 !== 0) { 135 | throw new Error(`Cannot parse text as coordinates: "${text}"`) 136 | } 137 | let points = [] 138 | while (textTokens.length > 0) { 139 | let point = textTokens.splice(0, 3) 140 | point = point.map(p => parseFloat(p)) 141 | point = point.map(p => isNaN(p) ? 0 : p) 142 | point = srsTranslator.forward(point, srs, 'WGS84') 143 | point = Cesium.Cartographic.fromDegrees(point[0], point[1], point[2]) 144 | points.push(point) 145 | } 146 | return points 147 | } 148 | 149 | /** 150 | * @returns {Cesium.Cartesian3[]} 151 | */ 152 | getTextAsCoordinatesCartesian () { 153 | return this.getTextAsCoordinatesCartographic().map((point) => { 154 | return Cesium.Cartographic.toCartesian(point) 155 | }) 156 | } 157 | 158 | /** 159 | * @returns {Cesium.Cartographic} 160 | */ 161 | getTextAsCoordinates1Cartographic () { 162 | let coords = this.getTextAsCoordinatesCartographic() 163 | if (coords.length !== 1) { 164 | throw new Error('Expected 1 coordinates, but found ' + coords.length) 165 | } 166 | return coords[0] 167 | } 168 | 169 | /** 170 | * @returns {Cesium.Cartesian3} 171 | */ 172 | getTextAsCoordinates1Cartesian () { 173 | let point = this.getTextAsCoordinates1Cartographic() 174 | return Cesium.Cartographic.toCartesian(point) 175 | } 176 | 177 | /** 178 | * @returns {String} 179 | */ 180 | getSRS () { 181 | /** 182 | * @todo: currently assuming the whole document has the same SRS. 183 | * Instead should recursively iterate from current node through parent nodes. 184 | * See http://en.wiki.quality.sig3d.org/index.php/Modeling_Guide_for_3D_Objects_-_Part_1:_Basics_(Rules_for_Validating_GML_Geometries_in_CityGML)#Spatial_Reference_Systems_.28SRS.29 185 | */ 186 | let srsCacheId = this.xmlNode.ownerDocument.srsCacheId 187 | if (!srsCacheId) { 188 | srsCacheId = Object.keys(srsCache).length + 1 189 | this.xmlNode.ownerDocument.srsCacheId = srsCacheId 190 | } 191 | 192 | let srs = srsCache[srsCacheId] 193 | if (!srs) { 194 | srs = this.selectNode('//@srsName').value 195 | if (!srs) { 196 | throw new Error('Cannot detect SRS') 197 | } 198 | srsCache[srsCacheId] = srs 199 | } 200 | return srs 201 | } 202 | 203 | /** 204 | * @param {String} expression 205 | * @param {Boolean} single 206 | * @return {Node|Node[]} 207 | * @private 208 | */ 209 | _select (expression, single) { 210 | expression = CityNode._preprocessXpathExpression(expression) 211 | return xpath.selectWithResolver(expression, this.xmlNode, xpathResolver, single) 212 | } 213 | 214 | /** 215 | * @param {String} expression 216 | * @returns {String} 217 | * @private 218 | */ 219 | static _preprocessXpathExpression (expression) { 220 | /** 221 | * Support XPath 2.0-style "or" 222 | * i.e. replace `(foo|bar)` with `*[self::foo or self::bar]`. 223 | */ 224 | expression = expression.replace(/\(([\w\d:|]+?)\)/, (_, p1) => { 225 | let tagNames = p1.split('|') 226 | return '*[' + tagNames.map(n => `self::${n}`).join(' or ') + ']' 227 | }) 228 | return expression 229 | } 230 | } 231 | 232 | let namespaceMapping = { 233 | 'gml': 'http://www.opengis.net/gml', 234 | 235 | 'citygml1': 'http://www.opengis.net/citygml/1.0', 236 | 'base1': 'http://www.opengis.net/citygml/base/1.0', 237 | 'gen1': 'http://www.opengis.net/citygml/generics/1.0', 238 | 'bldg1': 'http://www.opengis.net/citygml/building/1.0', 239 | 240 | 'citygml2': 'http://www.opengis.net/citygml/2.0', 241 | 'base2': 'http://www.opengis.net/citygml/base/2.0', 242 | 'gen2': 'http://www.opengis.net/citygml/generics/2.0', 243 | 'bldg2': 'http://www.opengis.net/citygml/building/2.0', 244 | } 245 | 246 | let xpathResolver = { 247 | lookupNamespaceURI: function (prefix) { 248 | return namespaceMapping[prefix] 249 | } 250 | } 251 | 252 | export default CityNode 253 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | citygml-to-3dtiles 2 | ================== 3 | 4 | [![Build Status](https://img.shields.io/github/actions/workflow/status/njam/citygml-to-3dtiles/test.yml)](https://github.com/njam/citygml-to-3dtiles/actions/workflows/test.yml) 5 | [![npm](https://img.shields.io/npm/v/citygml-to-3dtiles.svg)](https://www.npmjs.com/package/citygml-to-3dtiles) 6 | 7 | A very *basic and experimental* converter from [CityGML](https://www.citygml.org/) to [Cesium 3D Tiles](https://github.com/CesiumGS/3d-tiles). 8 | 9 | Note that this tool is generating 3D Tiles v1.0, while the current format is v1.1. 10 | 11 | About 12 | ----- 13 | 14 | The purpose of this JavaScript code is to read CityGML files, extract objects (like buildings), 15 | and write the corresponding meshes as a [batched 3D model](https://github.com/AnalyticalGraphicsInc/3d-tiles/blob/master/TileFormats/Batched3DModel/README.md) in the *3D Tiles* spec. 16 | Each building will become a *feature*, and its attributes will be stored in the *3D Tiles batch table*. 17 | For more information see [docs/background.md](docs/background.md). 18 | 19 | The code for writing *3D Tiles* files is based on the [3D Tiles Samples Generator](https://github.com/AnalyticalGraphicsInc/3d-tiles-tools/tree/master/samples-generator). 20 | 21 | The functionality is *very* basic, and many **limitations** exist: 22 | - Only city objects of type `Building` are converted. 23 | - Textures are not converted. 24 | - Only a single *B3DM* file is generated. (This works fine for small data sets, for larger sets probably a hierarchy of multiple files with different resolutions should be generated.) 25 | - Files larger than 2GB cannot be converted because of the limits of NodeJS' `Buffer`. 26 | 27 | Usage 28 | ----- 29 | 30 | ### CLI Script 31 | The library provides an executable to convert files on the command line. 32 | 1. Make sure you have NodeJS version 13+ installed! 33 | ``` 34 | node --version 35 | ``` 36 | 2. Install the NPM package 37 | ``` 38 | npm install -g citygml-to-3dtiles 39 | ``` 40 | 3. Use the script to convert CityGML to 3D Tiles 41 | ``` 42 | citygml-to-3dtiles "my-citygml.xml" "my-output-folder/" 43 | ``` 44 | 45 | When converting large files, it can be necessary to increase NodeJS' memory limit. 46 | For example run the conversion with a 10GB memory limit: 47 | ``` 48 | NODE_OPTIONS=--max-old-space-size=10000 citygml-to-3dtiles "my-citygml.xml" "my-output-folder/" 49 | ``` 50 | 51 | Instead of a single input file the script also accepts a path to a folder containing multiple CityGML files. 52 | The contents of all files will get concatenated and written to a single 3D Tileset. 53 | 54 | ### Programmatic Usage 55 | The library exposes an easy to use API to convert from CityGML to 3D Tiles. 56 | Using the library programmatically allows us to calculate custom *properties* and store them in the resulting tileset. 57 | ```js 58 | import Converter from "citygml-to-3dtiles"; 59 | 60 | let converter = new Converter(); 61 | await converter.convertFiles('./input.xml', './output/'); 62 | ``` 63 | 64 | #### Option: `propertiesGetter` 65 | By default any CityGML *attributes* and *external references* are stored in the 3D Tiles *batch table*. 66 | Additional properties can be stored per feature by passing `propertiesGetter`. 67 | The function `propertiesGetter` is executed once for each city object and the key/value pairs of the return value will become the model's properties. 68 | The function receives two arguments: 69 | - **cityObject**: An instance of type `CityObject`. Can be used to retrieve the object's geometry and to access the corresponding XML node from the CityGML. 70 | - **properties**: Key/value pairs of all the *attributes* and *external references* from the CityGML. Usually each building will have some kind of identifier assigned to it, which could be used to link the data to other data sets. That way we can blend in other tables of data referencing the same buildings. 71 | 72 | 73 | Example: Get the value of an element `` in the XML namespace "bldg2" (http://www.opengis.net/citygml/building/2.0) 74 | and store it as "measuredHeight" in the batch table: 75 | ```js 76 | let converter = new Converter({ 77 | propertiesGetter: (cityObject, properties) => { 78 | let measuredHeightNode = cityObject.cityNode.selectNode('./bldg2:measuredHeight'); 79 | let measuredHeight = parseFloat(measuredHeightNode.textContent); 80 | return { 81 | measuredHeight: measuredHeight 82 | } 83 | } 84 | }); 85 | await converter.convertFiles('./input.xml', './output/'); 86 | ``` 87 | 88 | Example: Store the convex surface area of each building (calculated from the geometry) in the property "surfaceArea": 89 | ```js 90 | let converter = new Converter({ 91 | propertiesGetter: (cityObject, properties) => { 92 | let mesh = cityObject.getTriangleMesh(); 93 | return { 94 | surfaceArea: mesh.getSurfaceArea() 95 | } 96 | } 97 | }); 98 | await converter.convertFiles('./input.xml', './output/'); 99 | ``` 100 | 101 | #### Option: `objectFilter` 102 | Allows to remove city objects (buildings) based on a callback function. 103 | The function specified receives the city object (`CityObject`) as its only argument and should return `true` or `false` to decide 104 | whether to include it in the 3DTiles output. 105 | 106 | Example: Only include objects with a maximum distance of 600m of a given point. 107 | ```js 108 | let center = Cesium.Cartesian3.fromDegrees(8.5177282, 47.3756023); 109 | let converter = new Converter({ 110 | objectFilter: (cityObject) => { 111 | let distance = Cesium.Cartesian3.distance(center, cityObject.getAnyPoint()); 112 | return distance < 600; 113 | } 114 | }); 115 | await converter.convertFiles('./input.xml', './output/'); 116 | ``` 117 | 118 | #### Option: `srsProjections` 119 | The coordinates in CityGML are defined in a certain *spatial reference system* (SRS). 120 | This script assumes that all coordinates in a document are using the *same* SRS. 121 | Height component of coordinates is *not* transformed according to the SRS, because the library used ([proj4js](https://github.com/proj4js/proj4js)) doesn't support it. 122 | 123 | Only a few SRS are defined by default. Additional SRS can be passed to the converter: 124 | ``` 125 | let converter = new Converter({ 126 | srsProjections: { 127 | 'CH1903': '+proj=somerc +lat_0=46.95240555555556 +lon_0=7.439583333333333 +k_0=1 +x_0=600000 +y_0=200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs', 128 | } 129 | }); 130 | await converter.convertFiles('./input.xml', './output/'); 131 | ``` 132 | 133 | The definition should be in the "PROJ.4" format. Definitions in that format can be found at http://epsg.io/. 134 | 135 | Examples 136 | -------- 137 | 138 | For *Delft* an area is available in CityGML2 LOD1 from [TU Delft](https://3d.bk.tudelft.nl/opendata/3dfier/). 139 | ![](docs/delft.png) 140 | 141 | For *Zürich* a small area of LOD2 CityGML is available from [Stadt Zürich, Geomatik Vermessung](https://www.stadt-zuerich.ch/ted/de/index/geoz/geodaten_u_plaene/3d_stadtmodell/demodaten.html). 142 | Here the buildings' hull volume is color coded in Cesium: 143 | ![](docs/zurich-lod2.png) 144 | 145 | Development 146 | ----------- 147 | Install dependencies: 148 | ``` 149 | npm install 150 | ``` 151 | 152 | Run tests: 153 | ``` 154 | npm test 155 | ``` 156 | 157 | Release a new version: 158 | 1. Check out the latest version 159 | - `git checkout master && git pull --ff-only` 160 | 2. Bump version in `package.json`, run `npm install` and commit the change. 161 | 3. Push a new tag to master 162 | - `git tag "vX.Y.Z"` 163 | - `git push && git push --tags` 164 | 4. The Github-Actions CI will deploy to NPM 165 | -------------------------------------------------------------------------------- /src/3dtiles/Mesh.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on: 3 | * https://github.com/AnalyticalGraphicsInc/3d-tiles-tools/blob/master/samples-generator/lib/Mesh.js 4 | */ 5 | import Cesium from 'cesium' 6 | import Material from './Material.mjs' 7 | 8 | export default Mesh 9 | 10 | let Cartesian3 = Cesium.Cartesian3 11 | let Cartesian2 = Cesium.Cartesian2 12 | let defined = Cesium.defined 13 | 14 | const whiteOpaqueMaterial = new Material({ 15 | diffuse: [0.5, 0.5, 0.5, 1.0], 16 | ambient: [1.0, 1.0, 1.0, 1.0] 17 | }) 18 | 19 | /** 20 | * Stores the vertex attributes and indices describing a mesh. 21 | * 22 | * @param {Object} options Object with the following properties: 23 | * @param {Number[]} options.indices An array of integers representing the mesh indices. 24 | * @param {Number[]} options.positions A packed array of floats representing the mesh positions. 25 | * @param {Number[]} options.normals A packed array of floats representing the mesh normals. 26 | * @param {Number[]} options.uvs A packed array of floats representing the mesh UVs. 27 | * @param {Number[]} options.vertexColors A packed array of integers representing the vertex colors. 28 | * @param {Number[]} [options.batchIds] An array of integers representing the batch ids. 29 | * @param {Material} [options.material] A material to apply to the mesh. 30 | * @param {MeshView[]} [options.views] An array of MeshViews. 31 | * 32 | * @constructor 33 | */ 34 | function Mesh (options) { 35 | this.indices = options.indices 36 | this.positions = options.positions 37 | this.normals = options.normals 38 | this.uvs = options.uvs 39 | this.vertexColors = options.vertexColors 40 | this.batchIds = options.batchIds 41 | this.material = options.material 42 | this.views = options.views 43 | } 44 | 45 | /** 46 | * A subsection of the mesh with its own material. 47 | * 48 | * @param {Object} options Object with the following properties: 49 | * @param {Material} options.material The material. 50 | * @param {Number} options.indexOffset The start index into the mesh's indices array. 51 | * @param {Number} options.indexCount The number of indices. 52 | * 53 | * @constructor 54 | * @private 55 | */ 56 | function MeshView (options) { 57 | this.material = options.material 58 | this.indexOffset = options.indexOffset 59 | this.indexCount = options.indexCount 60 | } 61 | 62 | let scratchCartesian = new Cartesian3() 63 | 64 | /** 65 | * Get the number of vertices in the mesh. 66 | * 67 | * @returns {Number} The number of vertices. 68 | */ 69 | Mesh.prototype.getVertexCount = function () { 70 | return this.positions.length / 3 71 | } 72 | 73 | /** 74 | * Get the center of the mesh. 75 | * 76 | * @returns {Cartesian3} The center position 77 | */ 78 | Mesh.prototype.getCenter = function () { 79 | let center = new Cartesian3() 80 | let positions = this.positions 81 | let vertexCount = this.getVertexCount() 82 | for (let i = 0; i < vertexCount; ++i) { 83 | let position = Cartesian3.unpack(positions, i * 3, scratchCartesian) 84 | Cartesian3.add(position, center, center) 85 | } 86 | Cartesian3.divideByScalar(center, vertexCount, center) 87 | return center 88 | } 89 | 90 | /** 91 | * Set the positions relative to center. 92 | */ 93 | Mesh.prototype.setPositionsRelativeToCenter = function () { 94 | let positions = this.positions 95 | let center = this.getCenter() 96 | let vertexCount = this.getVertexCount() 97 | for (let i = 0; i < vertexCount; ++i) { 98 | let position = Cartesian3.unpack(positions, i * 3, scratchCartesian) 99 | Cartesian3.subtract(position, center, position) 100 | Cartesian3.pack(position, positions, i * 3) 101 | } 102 | } 103 | 104 | /** 105 | * Bake materials as vertex colors. Use the default white opaque material. 106 | */ 107 | Mesh.prototype.transferMaterialToVertexColors = function () { 108 | let material = this.material 109 | this.material = whiteOpaqueMaterial 110 | let vertexCount = this.getVertexCount() 111 | let vertexColors = new Array(vertexCount * 4) 112 | this.vertexColors = vertexColors 113 | for (let i = 0; i < vertexCount; ++i) { 114 | vertexColors[i * 4 + 0] = Math.floor(material.diffuse[0] * 255) 115 | vertexColors[i * 4 + 1] = Math.floor(material.diffuse[1] * 255) 116 | vertexColors[i * 4 + 2] = Math.floor(material.diffuse[2] * 255) 117 | vertexColors[i * 4 + 3] = Math.floor(material.diffuse[3] * 255) 118 | } 119 | } 120 | 121 | function appendToArray (array, append) { 122 | append.forEach(function (item) { 123 | array.push(item) 124 | }) 125 | } 126 | 127 | /** 128 | * Batch multiple meshes into a single mesh. Assumes the input meshes do not already have batch ids. 129 | * 130 | * @param {Mesh[]} meshes The meshes that will be batched together. 131 | * @returns {Mesh} The batched mesh. 132 | */ 133 | Mesh.batch = function (meshes) { 134 | let batchedPositions = [] 135 | let batchedNormals = [] 136 | let batchedUvs = [] 137 | let batchedVertexColors = [] 138 | let batchedBatchIds = [] 139 | let batchedIndices = [] 140 | 141 | let startIndex = 0 142 | let indexOffset = 0 143 | let views = [] 144 | let currentView 145 | let meshesLength = meshes.length 146 | for (let i = 0; i < meshesLength; ++i) { 147 | let mesh = meshes[i] 148 | let positions = mesh.positions 149 | let normals = mesh.normals 150 | let uvs = mesh.uvs 151 | let vertexColors = mesh.vertexColors 152 | let vertexCount = mesh.getVertexCount() 153 | 154 | // Generate batch ids for this mesh 155 | let batchIds = new Array(vertexCount).fill(i) 156 | 157 | appendToArray(batchedPositions, positions) 158 | appendToArray(batchedNormals, normals) 159 | appendToArray(batchedUvs, uvs) 160 | appendToArray(batchedVertexColors, vertexColors) 161 | appendToArray(batchedBatchIds, batchIds) 162 | 163 | // Generate indices and mesh views 164 | let indices = mesh.indices 165 | let indicesLength = indices.length 166 | 167 | if (!defined(currentView) || (currentView.material !== mesh.material)) { 168 | currentView = new MeshView({ 169 | material: mesh.material, 170 | indexOffset: indexOffset, 171 | indexCount: indicesLength 172 | }) 173 | views.push(currentView) 174 | } else { 175 | currentView.indexCount += indicesLength 176 | } 177 | 178 | for (let j = 0; j < indicesLength; ++j) { 179 | let index = indices[j] + startIndex 180 | batchedIndices.push(index) 181 | } 182 | startIndex += vertexCount 183 | indexOffset += indicesLength 184 | } 185 | 186 | return new Mesh({ 187 | indices: batchedIndices, 188 | positions: batchedPositions, 189 | normals: batchedNormals, 190 | uvs: batchedUvs, 191 | vertexColors: batchedVertexColors, 192 | batchIds: batchedBatchIds, 193 | views: views 194 | }) 195 | } 196 | 197 | /** 198 | * Clone the mesh geometry and create a new mesh. Assumes the input mesh does not already have batch ids. 199 | * 200 | * @param {Mesh} mesh The mesh to clone. 201 | * @returns {Mesh} The cloned mesh. 202 | */ 203 | Mesh.clone = function (mesh) { 204 | return new Mesh({ 205 | positions: mesh.positions.slice(), 206 | normals: mesh.normals.slice(), 207 | uvs: mesh.uvs.slice(), 208 | vertexColors: mesh.vertexColors.slice(), 209 | indices: mesh.indices.slice(), 210 | material: mesh.material 211 | }) 212 | } 213 | 214 | /** 215 | * @param {TriangleMesh} triangleMesh 216 | * @return {Mesh} 217 | */ 218 | Mesh.fromTriangleMesh = function (triangleMesh) { 219 | let vertices = [] 220 | let normals = [] 221 | triangleMesh.getTriangles().forEach(triangle => { 222 | vertices = vertices.concat(triangle.getVertices()) 223 | let normal = triangle.getNormal() 224 | triangle.getVertices().forEach(vertex => { 225 | normals = normals.concat(normal) 226 | }) 227 | }) 228 | let indices = vertices.map((vertex, i) => { 229 | return i 230 | }) 231 | let uvs = vertices.map(vertex => { 232 | return new Cesium.Cartesian2(0, 0) 233 | }) 234 | 235 | return new Mesh({ 236 | indices: indices, 237 | positions: flattenVertices(vertices), 238 | normals: flattenVertices(normals), 239 | uvs: flattenVertices(uvs), 240 | vertexColors: new Array(vertices.length * 4).fill(0), 241 | material: whiteOpaqueMaterial 242 | }) 243 | } 244 | 245 | /** 246 | * @param {Cesium.Cartesian3[]} vertices 247 | * @returns {Number[]} 248 | */ 249 | function flattenVertices (vertices) { 250 | let arr = [] 251 | vertices.forEach(vertex => { 252 | if (vertex instanceof Cartesian3) { 253 | arr = arr.concat(Cartesian3.pack(vertex, [])) 254 | } else if (vertex instanceof Cartesian2) { 255 | arr = arr.concat(Cartesian2.pack(vertex, [])) 256 | } else { 257 | throw new Error('Unknown vertex type') 258 | } 259 | }) 260 | return arr 261 | } -------------------------------------------------------------------------------- /test/data/sig3d-genericattributes-citygml2.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | Beispiel für generische Attribute 12 | 13 | 14 | 458868.0 5438343.0 0.0 15 | 458878.0 5438351.0 9.0 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | Beispiel für ein Haus mit generischen 24 | Attributen 25 | Haus mit generischen Attributen 26 | 27 | 28 | 29 | 30 | Massivbau 31 | 32 | 33 | 3 34 | 35 | 36 | 0.33 37 | 38 | 39 | 2012-03-09 40 | 41 | 42 | http://www.sig3d.org 43 | 44 | 45 | 20.75 46 | 47 | 48 | 49 | 50 | 51 | 52 | 9.00 53 | 54 | 55 | 140.00 56 | 57 | 58 | 1260.00 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 73 | 458868 5438343 0 74 | 458868 5438351 0 75 | 458878 5438351 0 76 | 458878 5438343 0 77 | 458868 5438343 0 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 87 | 458868 5438351 9 88 | 458868 5438343 9 89 | 458878 5438343 9 90 | 458878 5438351 9 91 | 458868 5438351 9 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 101 | 458868 5438351 0 102 | 458868 5438343 0 103 | 458868 5438343 9 104 | 458868 5438351 9 105 | 458868 5438351 0 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 115 | 458878 5438351 0 116 | 458868 5438351 0 117 | 458868 5438351 9 118 | 458878 5438351 9 119 | 458878 5438351 0 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 129 | 458878 5438343 0 130 | 458878 5438351 0 131 | 458878 5438351 9 132 | 458878 5438343 9 133 | 458878 5438343 0 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 143 | 458868 5438343 0 144 | 458878 5438343 0 145 | 458878 5438343 9 146 | 458868 5438343 9 147 | 458868 5438343 0 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /src/3dtiles/createGltf.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on: 3 | * https://github.com/AnalyticalGraphicsInc/3d-tiles-tools/blob/master/samples-generator/lib/createGltf.js 4 | */ 5 | import Cesium from 'cesium' 6 | import fsExtra from 'fs-extra' 7 | import gltfPipeline from 'gltf-pipeline' 8 | import mime from 'mime' 9 | import path from 'path' 10 | import Promise from 'bluebird' 11 | 12 | export default createGltf 13 | 14 | let Cartesian3 = Cesium.Cartesian3 15 | let combine = Cesium.combine 16 | 17 | let defaultValue = Cesium.defaultValue 18 | let addPipelineExtras = gltfPipeline.addPipelineExtras 19 | let getBinaryGltf = gltfPipeline.getBinaryGltf 20 | let loadGltfUris = gltfPipeline.loadGltfUris 21 | 22 | let processGltf = gltfPipeline.Pipeline.processJSON 23 | 24 | let sizeOfUint8 = 1 25 | let sizeOfUint16 = 2 26 | let sizeOfUint32 = 4 27 | let sizeOfFloat32 = 4 28 | 29 | /** 30 | * Create a glTF from a Mesh. 31 | * 32 | * @param {Object} options An object with the following properties: 33 | * @param {Mesh} options.mesh The mesh. 34 | * @param {Boolean} [options.useBatchIds=true] Modify the glTF to include the batchId vertex attribute. 35 | * @param {Boolean} [options.optimizeForCesium=false] Optimize the glTF for Cesium by using the sun as a default light source. 36 | * @param {Boolean} [options.relativeToCenter=false] Use the Cesium_RTC extension. 37 | * @param {Boolean} [options.khrMaterialsCommon=false] Save glTF with the KHR_materials_common extension. 38 | * @param {Boolean} [options.quantization=false] Save glTF with quantized attributes. 39 | * @param {Boolean} [options.deprecated=false] Save the glTF with the old BATCHID semantic. 40 | * @param {Object|Object[]} [options.textureCompressionOptions] Options for compressing textures in the glTF. 41 | * @param {String} [options.upAxis='Y'] Specifies the up-axis for the glTF model. 42 | * 43 | * @returns {Promise} A promise that resolves with the binary glTF buffer. 44 | */ 45 | function createGltf (options) { 46 | let useBatchIds = defaultValue(options.useBatchIds, true) 47 | let optimizeForCesium = defaultValue(options.optimizeForCesium, false) 48 | let relativeToCenter = defaultValue(options.relativeToCenter, false) 49 | let khrMaterialsCommon = defaultValue(options.khrMaterialsCommon, false) 50 | let quantization = defaultValue(options.quantization, false) 51 | let deprecated = defaultValue(options.deprecated, false) 52 | let textureCompressionOptions = options.textureCompressionOptions 53 | let upAxis = defaultValue(options.upAxis, 'Y') 54 | 55 | let mesh = options.mesh 56 | let positions = mesh.positions 57 | let normals = mesh.normals 58 | let uvs = mesh.uvs 59 | let vertexColors = mesh.vertexColors 60 | let indices = mesh.indices 61 | let views = mesh.views 62 | 63 | // If all the vertex colors are 0 then the mesh does not have vertex colors 64 | let hasVertexColors = !vertexColors.every(function (element) { 65 | return element === 0 66 | }) 67 | 68 | // Get the center position in WGS84 coordinates 69 | let center 70 | if (relativeToCenter) { 71 | center = mesh.getCenter() 72 | mesh.setPositionsRelativeToCenter() 73 | } 74 | 75 | let rootMatrix 76 | if (upAxis === 'Y') { 77 | // Models are z-up, so add a z-up to y-up transform. 78 | // The glTF spec defines the y-axis as up, so this is the default behavior. 79 | // In Cesium a y-up to z-up transform is applied later so that the glTF and 3D Tiles coordinate systems are consistent 80 | rootMatrix = [1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1] 81 | } else if (upAxis === 'Z') { 82 | // No conversion needed - models are already z-up 83 | rootMatrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] 84 | } 85 | 86 | let i 87 | let positionsMinMax = getMinMax(positions, 3) 88 | let positionsLength = positions.length 89 | let positionsBuffer = Buffer.alloc(positionsLength * sizeOfFloat32) 90 | for (i = 0; i < positionsLength; ++i) { 91 | positionsBuffer.writeFloatLE(positions[i], i * sizeOfFloat32) 92 | } 93 | 94 | let normalsMinMax = getMinMax(normals, 3) 95 | let normalsLength = normals.length 96 | let normalsBuffer = Buffer.alloc(normalsLength * sizeOfFloat32) 97 | for (i = 0; i < normalsLength; ++i) { 98 | normalsBuffer.writeFloatLE(normals[i], i * sizeOfFloat32) 99 | } 100 | 101 | let uvsMinMax = getMinMax(uvs, 2) 102 | let uvsLength = uvs.length 103 | let uvsBuffer = Buffer.alloc(uvsLength * sizeOfFloat32) 104 | for (i = 0; i < uvsLength; ++i) { 105 | uvsBuffer.writeFloatLE(uvs[i], i * sizeOfFloat32) 106 | } 107 | 108 | let vertexColorsMinMax 109 | let vertexColorsBuffer = Buffer.alloc(0) 110 | if (hasVertexColors) { 111 | vertexColorsMinMax = getMinMax(vertexColors, 4) 112 | let vertexColorsLength = vertexColors.length 113 | vertexColorsBuffer = Buffer.alloc(vertexColorsLength, sizeOfUint8) 114 | for (i = 0; i < vertexColorsLength; ++i) { 115 | vertexColorsBuffer.writeUInt8(vertexColors[i], i) 116 | } 117 | } 118 | 119 | let indicesLength = indices.length 120 | let indexBuffer = Buffer.alloc(indicesLength * sizeOfUint32) 121 | for (i = 0; i < indicesLength; ++i) { 122 | indexBuffer.writeUInt32LE(indices[i], i * sizeOfUint32) 123 | } 124 | 125 | let vertexBuffer = Buffer.concat([positionsBuffer, normalsBuffer, uvsBuffer, vertexColorsBuffer]) 126 | let vertexBufferByteOffset = 0 127 | let vertexBufferByteLength = vertexBuffer.byteLength 128 | let vertexCount = mesh.getVertexCount() 129 | 130 | let indexBufferByteOffset = vertexBufferByteLength 131 | let indexBufferByteLength = indexBuffer.byteLength 132 | 133 | let buffer = Buffer.concat([vertexBuffer, indexBuffer]) 134 | let bufferUri = 'data:application/octet-stream;base64,' + buffer.toString('base64') 135 | let byteLength = buffer.byteLength 136 | 137 | let byteOffset = 0 138 | let positionsByteOffset = byteOffset 139 | byteOffset += positionsBuffer.length 140 | let normalsByteOffset = byteOffset 141 | byteOffset += normalsBuffer.length 142 | let uvsByteOffset = byteOffset 143 | byteOffset += uvsBuffer.length 144 | 145 | let vertexColorsByteOffset = byteOffset 146 | if (hasVertexColors) { 147 | byteOffset += vertexColorsBuffer.length 148 | } 149 | 150 | let indexAccessors = {} 151 | let materials = {} 152 | let primitives = [] 153 | let images = {} 154 | let samplers = {} 155 | let textures = {} 156 | 157 | let viewsLength = views.length 158 | for (i = 0; i < viewsLength; ++i) { 159 | let view = views[i] 160 | let material = view.material 161 | let accessorName = 'accessor_index_' + i 162 | let materialName = 'material_' + i 163 | let indicesMinMax = getMinMax(indices, 1, view.indexOffset, view.indexCount) 164 | indexAccessors[accessorName] = { 165 | bufferView: 'bufferView_index', 166 | byteOffset: sizeOfUint32 * view.indexOffset, 167 | byteStride: 0, 168 | componentType: 5125, // UNSIGNED_INT 169 | count: view.indexCount, 170 | type: 'SCALAR', 171 | min: indicesMinMax.min, 172 | max: indicesMinMax.max 173 | } 174 | 175 | let ambient = material.ambient 176 | let diffuse = material.diffuse 177 | let emission = material.emission 178 | let specular = material.specular 179 | let shininess = material.shininess 180 | let transparent = false 181 | 182 | if (typeof diffuse === 'string') { 183 | images.image_diffuse = { 184 | uri: diffuse 185 | } 186 | samplers.sampler_diffuse = { 187 | magFilter: 9729, // LINEAR 188 | minFilter: 9729, // LINEAR 189 | wrapS: 10497, // REPEAT 190 | wrapT: 10497 // REPEAT 191 | } 192 | textures.texture_diffuse = { 193 | format: 6408, // RGBA 194 | internalFormat: 6408, // RGBA 195 | sampler: 'sampler_diffuse', 196 | source: 'image_diffuse', 197 | target: 3553, // TEXTURE_2D 198 | type: 5121 // UNSIGNED_BYTE 199 | } 200 | 201 | diffuse = 'texture_diffuse' 202 | } else { 203 | transparent = diffuse[3] < 1.0 204 | } 205 | 206 | let doubleSided = transparent 207 | let technique = (shininess > 0.0) ? 'PHONG' : 'LAMBERT' 208 | 209 | materials[materialName] = { 210 | extensions: { 211 | KHR_materials_common: { 212 | technique: technique, 213 | transparent: transparent, 214 | doubleSided: doubleSided, 215 | values: { 216 | ambient: ambient, 217 | diffuse: diffuse, 218 | emission: emission, 219 | specular: specular, 220 | shininess: shininess, 221 | transparency: 1.0, 222 | transparent: transparent, 223 | doubleSided: doubleSided 224 | } 225 | } 226 | } 227 | } 228 | 229 | let attributes = { 230 | POSITION: 'accessor_position', 231 | NORMAL: 'accessor_normal', 232 | TEXCOORD_0: 'accessor_uv' 233 | } 234 | 235 | if (hasVertexColors) { 236 | attributes.COLOR_0 = 'accessor_vertexColor' 237 | } 238 | 239 | primitives.push({ 240 | attributes: attributes, 241 | indices: accessorName, 242 | material: materialName, 243 | mode: 4 // TRIANGLES 244 | }) 245 | } 246 | 247 | let vertexAccessors = { 248 | accessor_position: { 249 | bufferView: 'bufferView_vertex', 250 | byteOffset: positionsByteOffset, 251 | byteStride: 0, 252 | componentType: 5126, // FLOAT 253 | count: vertexCount, 254 | type: 'VEC3', 255 | min: positionsMinMax.min, 256 | max: positionsMinMax.max 257 | }, 258 | accessor_normal: { 259 | bufferView: 'bufferView_vertex', 260 | byteOffset: normalsByteOffset, 261 | byteStride: 0, 262 | componentType: 5126, // FLOAT 263 | count: vertexCount, 264 | type: 'VEC3', 265 | min: normalsMinMax.min, 266 | max: normalsMinMax.max 267 | }, 268 | accessor_uv: { 269 | bufferView: 'bufferView_vertex', 270 | byteOffset: uvsByteOffset, 271 | byteStride: 0, 272 | componentType: 5126, // FLOAT 273 | count: vertexCount, 274 | type: 'VEC2', 275 | min: uvsMinMax.min, 276 | max: uvsMinMax.max 277 | } 278 | } 279 | 280 | if (hasVertexColors) { 281 | vertexAccessors.accessor_vertexColor = { 282 | bufferView: 'bufferView_vertex', 283 | byteOffset: vertexColorsByteOffset, 284 | byteStride: 0, 285 | componentType: 5121, // UNSIGNED_BYTE 286 | count: vertexCount, 287 | type: 'VEC4', 288 | min: vertexColorsMinMax.min, 289 | max: vertexColorsMinMax.max, 290 | normalized: true 291 | } 292 | } 293 | 294 | let accessors = combine(vertexAccessors, indexAccessors) 295 | 296 | let gltf = { 297 | accessors: accessors, 298 | asset: { 299 | generator: '3d-tiles-samples-generator', 300 | version: '1.0', 301 | profile: { 302 | api: 'WebGL', 303 | version: '1.0' 304 | } 305 | }, 306 | buffers: { 307 | buffer: { 308 | byteLength: byteLength, 309 | uri: bufferUri 310 | } 311 | }, 312 | bufferViews: { 313 | bufferView_vertex: { 314 | buffer: 'buffer', 315 | byteLength: vertexBufferByteLength, 316 | byteOffset: vertexBufferByteOffset, 317 | target: 34962 // ARRAY_BUFFER 318 | }, 319 | bufferView_index: { 320 | buffer: 'buffer', 321 | byteLength: indexBufferByteLength, 322 | byteOffset: indexBufferByteOffset, 323 | target: 34963 // ELEMENT_ARRAY_BUFFER 324 | } 325 | }, 326 | extensionsUsed: ['KHR_materials_common'], 327 | images: images, 328 | materials: materials, 329 | meshes: { 330 | mesh: { 331 | primitives: primitives 332 | } 333 | }, 334 | nodes: { 335 | rootNode: { 336 | matrix: rootMatrix, 337 | meshes: ['mesh'], 338 | name: 'rootNode' 339 | } 340 | }, 341 | samplers: samplers, 342 | scene: 'scene', 343 | scenes: { 344 | scene: { 345 | nodes: ['rootNode'] 346 | } 347 | }, 348 | textures: textures 349 | } 350 | 351 | let kmcOptions 352 | if (khrMaterialsCommon) { 353 | kmcOptions = { 354 | technique: 'LAMBERT', 355 | doubleSided: false 356 | } 357 | } 358 | 359 | let gltfOptions = { 360 | optimizeForCesium: optimizeForCesium, 361 | kmcOptions: kmcOptions, 362 | preserve: true, // Don't apply extra optimizations to the glTF 363 | quantize: quantization, 364 | compressTextureCoordinates: quantization, 365 | encodeNormals: quantization, 366 | textureCompressionOptions: textureCompressionOptions 367 | } 368 | 369 | return loadImages(gltf) 370 | .then(function () { 371 | // Run through the gltf-pipeline to generate techniques and shaders for the glTF 372 | return processGltf(gltf, gltfOptions) 373 | .then(function (gltf) { 374 | if (useBatchIds) { 375 | modifyGltfWithBatchIds(gltf, mesh, deprecated) 376 | } 377 | if (relativeToCenter) { 378 | modifyGltfWithRelativeToCenter(gltf, center) 379 | } 380 | if (optimizeForCesium) { 381 | modifyGltfForCesium(gltf) 382 | } 383 | return convertToBinaryGltf(gltf) 384 | }) 385 | }) 386 | } 387 | 388 | function getLoadImageFunction (image) { 389 | return function () { 390 | let imagePath = image.uri 391 | let extension = path.extname(imagePath) 392 | return fsExtra.readFile(imagePath) 393 | .then(function (buffer) { 394 | image.uri = 'data:' + mime.getType(extension) + ';base64,' + buffer.toString('base64') 395 | }) 396 | } 397 | } 398 | 399 | function loadImages (gltf) { 400 | let imagePromises = [] 401 | let images = gltf.images 402 | for (let id in images) { 403 | if (images.hasOwnProperty(id)) { 404 | let image = images[id] 405 | imagePromises.push(getLoadImageFunction(image)()) 406 | } 407 | } 408 | return Promise.all(imagePromises) 409 | } 410 | 411 | function convertToBinaryGltf (gltf) { 412 | addPipelineExtras(gltf) 413 | return loadGltfUris(gltf) 414 | .then(function (gltf) { 415 | return getBinaryGltf(gltf, true, true).glb 416 | }) 417 | } 418 | 419 | function modifyGltfWithBatchIds (gltf, mesh, deprecated) { 420 | let i 421 | let batchIds = mesh.batchIds 422 | let batchIdsMinMax = getMinMax(batchIds, 1) 423 | let batchIdsLength = batchIds.length 424 | let batchIdsBuffer = Buffer.alloc(batchIdsLength * sizeOfUint16) 425 | for (i = 0; i < batchIdsLength; ++i) { 426 | batchIdsBuffer.writeUInt16LE(batchIds[i], i * sizeOfUint16) 427 | } 428 | let batchIdsBufferUri = 'data:application/octet-stream;base64,' + batchIdsBuffer.toString('base64') 429 | let batchIdSemantic = deprecated ? 'BATCHID' : '_BATCHID' 430 | 431 | gltf.accessors.accessor_batchId = { 432 | bufferView: 'bufferView_batchId', 433 | byteOffset: 0, 434 | byteStride: 0, 435 | componentType: 5123, // UNSIGNED_SHORT 436 | count: batchIdsLength, 437 | type: 'SCALAR', 438 | min: batchIdsMinMax.min, 439 | max: batchIdsMinMax.max 440 | } 441 | 442 | gltf.bufferViews.bufferView_batchId = { 443 | buffer: 'buffer_batchId', 444 | byteLength: batchIdsBuffer.length, 445 | byteOffset: 0, 446 | target: 34962 // ARRAY_BUFFER 447 | } 448 | 449 | gltf.buffers.buffer_batchId = { 450 | byteLength: batchIdsBuffer.length, 451 | uri: batchIdsBufferUri 452 | } 453 | 454 | let meshes = gltf.meshes 455 | for (let meshId in meshes) { 456 | if (meshes.hasOwnProperty(meshId)) { 457 | let primitives = meshes[meshId].primitives 458 | let length = primitives.length 459 | for (i = 0; i < length; ++i) { 460 | let primitive = primitives[i] 461 | primitive.attributes[batchIdSemantic] = 'accessor_batchId' 462 | } 463 | } 464 | } 465 | 466 | let programs = gltf.programs 467 | for (let programId in programs) { 468 | if (programs.hasOwnProperty(programId)) { 469 | let program = programs[programId] 470 | program.attributes.push('a_batchId') 471 | } 472 | } 473 | 474 | let techniques = gltf.techniques 475 | for (let techniqueId in techniques) { 476 | if (techniques.hasOwnProperty(techniqueId)) { 477 | let technique = techniques[techniqueId] 478 | technique.attributes.a_batchId = 'batchId' 479 | technique.parameters.batchId = { 480 | semantic: batchIdSemantic, 481 | type: 5123 // UNSIGNED_SHORT 482 | } 483 | } 484 | } 485 | 486 | let shaders = gltf.shaders 487 | for (let shaderId in shaders) { 488 | if (shaders.hasOwnProperty(shaderId)) { 489 | let shader = shaders[shaderId] 490 | if (shader.type === 35633) { // Is a vertex shader 491 | let uriHeader = 'data:text/plain;base64,' 492 | let shaderEncoded = shader.uri.substring(uriHeader.length) 493 | let shaderText = Buffer.from(shaderEncoded, 'base64') 494 | shaderText = 'attribute float a_batchId;\n' + shaderText 495 | shaderEncoded = Buffer.from(shaderText).toString('base64') 496 | shader.uri = uriHeader + shaderEncoded 497 | } 498 | } 499 | } 500 | } 501 | 502 | function modifyGltfWithRelativeToCenter (gltf, center) { 503 | gltf.extensionsUsed = defaultValue(gltf.extensionsUsed, []) 504 | gltf.extensions = defaultValue(gltf.extensions, {}) 505 | 506 | gltf.extensionsUsed.push('CESIUM_RTC') 507 | gltf.extensions.CESIUM_RTC = { 508 | center: Cartesian3.pack(center, new Array(3)) 509 | } 510 | 511 | let techniques = gltf.techniques 512 | for (let techniqueId in techniques) { 513 | if (techniques.hasOwnProperty(techniqueId)) { 514 | let technique = techniques[techniqueId] 515 | let parameters = technique.parameters 516 | for (let parameterId in parameters) { 517 | if (parameters.hasOwnProperty(parameterId)) { 518 | if (parameterId === 'modelViewMatrix') { 519 | let parameter = parameters[parameterId] 520 | parameter.semantic = 'CESIUM_RTC_MODELVIEW' 521 | } 522 | } 523 | } 524 | } 525 | } 526 | } 527 | 528 | function modifyGltfForCesium (gltf) { 529 | // Add diffuse semantic to support colorBlendMode in Cesium 530 | let techniques = gltf.techniques 531 | for (let techniqueId in techniques) { 532 | if (techniques.hasOwnProperty(techniqueId)) { 533 | let technique = techniques[techniqueId] 534 | technique.parameters.diffuse.semantic = '_3DTILESDIFFUSE' 535 | } 536 | } 537 | } 538 | 539 | function getMinMax (array, components, start, length) { 540 | start = defaultValue(start, 0) 541 | length = defaultValue(length, array.length) 542 | let min = new Array(components).fill(Number.POSITIVE_INFINITY) 543 | let max = new Array(components).fill(Number.NEGATIVE_INFINITY) 544 | let count = length / components 545 | for (let i = 0; i < count; ++i) { 546 | for (let j = 0; j < components; ++j) { 547 | let index = start + i * components + j 548 | let value = array[index] 549 | min[j] = Math.min(min[j], value) 550 | max[j] = Math.max(max[j], value) 551 | } 552 | } 553 | return { 554 | min: min, 555 | max: max 556 | } 557 | } 558 | --------------------------------------------------------------------------------